Files
Robert Wojciechowski 6b94242110 feat: add Runpod serverless image Collector pools (#96)
* feat: add Runpod serverless image collector pools

* fix: prevent worker settings clipping and remove preview copy

* fix: initialize worker settings before mounting the form

* fix: validate complete serverless requests against 10 MB limit

* fix: simplify serverless cards and keep status dots circular

* fix: match serverless endpoint box to master card

* fix: preserve serverless capacity and recovery across job failures

Retain durable reservations until provider termination or explicit recovery, protect unresolved pools and credentials, and add scoped reconciliation. Consolidate lifecycle and secret policies and split prompt/media stages with regression coverage.

* fix(serverless): disable Clear saved API key while the worker is enabled

The checkbox read "Clear saved API key (disable pool first)", but the
panel never defines "pool" and saving with it ticked on an enabled worker
was rejected by the backend.

- Disable the checkbox while the worker is enabled, with a tooltip saying
  to turn the worker off first. Also disable it when no key is saved.
- Use "worker" instead of "pool" in user-facing serverless errors, the
  serverless guide and the README entry.

* feat(serverless): replace Concurrent jobs with a single Images per run setting

- Remove the separate Concurrent jobs field; rename Jobs per run to
  Images per run (1-16) with a tooltip explaining that Runpod's max
  workers decides how many GPUs run them.
- Backend uses a fixed per-worker limit of 16 outstanding jobs, so all
  images of a run are submitted at once. Unresolved jobs still count
  against it. The limit is constant, so it never has to change while
  jobs are unresolved.
- Legacy 'concurrency' in saved configs and older browser tabs is
  accepted and ignored/dropped; it no longer triggers the busy guard.
- Revert the enable toggle and show the reason when the backend rejects it.
- Trim a pasted api.runpod.ai/v2/<id> URL to the endpoint ID; plainer
  endpoint-ID error otherwise.
- Confirm before a type change deletes a saved Runpod API key.
- Update docs and tests.
2026-10-13 15:19:00 -07:00

797 lines
33 KiB
JavaScript

import { BUTTON_STYLES, UI_STYLES, STATUS_COLORS, UI_COLORS, TIMEOUTS } from './constants.js';
import { createButtonHelper as createButtonHelperFn } from './ui/buttonHelpers.js';
import { showCloudflareWarning as showCloudflareWarningFn } from './ui/cloudflareWarning.js';
import { createWorkerSettingsForm as createWorkerSettingsFormFn } from './ui/settingsForm.js';
import { renderEntityCard as renderEntityCardFn } from './ui/entityCard.js';
import { createLogModal } from './ui/logModal.js';
import { launchWorker, stopWorker, updateWorkerControls, viewWorkerLog } from './workerLifecycle.js';
import { isRemoteWorker } from './workerSettings.js';
const escapeWorkerInfo = (value) => String(value ?? "").replace(/[&<>"']/g, (character) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
}[character]));
const cardConfigs = {
master: {
checkbox: {
enabled: true,
masterToggle: true,
title: "Toggle master participation in workloads"
},
statusDot: {
id: 'master-status',
initialColor: (_, extension) => extension.isMasterParticipating() ? STATUS_COLORS.ONLINE_GREEN : STATUS_COLORS.DISABLED_GRAY,
initialTitle: (_, extension) => extension.isMasterParticipating() ? 'Master participating' : 'Master orchestrator only',
dynamic: true
},
infoText: (data, extension) => {
const cudaDevice = extension.config?.master?.cuda_device ?? extension.masterCudaDevice;
const cudaInfo = cudaDevice !== undefined ? `CUDA ${cudaDevice} • ` : '';
const port = window.location.port || (window.location.protocol === 'https:' ? '443' : '80');
const participationEnabled = extension.isMasterParticipationEnabled();
const fallbackActive = extension.isMasterFallbackActive();
let delegateBadge = '';
if (!participationEnabled && fallbackActive) {
delegateBadge = `<br><span class="dist-worker-info__fallback">Fallback active • Master executing</span>`;
}
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'
},
settings: {
formType: 'master',
id: 'master-settings',
expandedTracker: 'masterSettingsExpanded'
},
hover: true,
expand: true,
border: 'solid'
},
worker: {
checkbox: {
enabled: true,
title: "Enable/disable this worker"
},
statusDot: {
dynamic: true,
initialColor: (data) => data.enabled ? STATUS_COLORS.OFFLINE_RED : STATUS_COLORS.DISABLED_GRAY,
initialTitle: (data) => data.enabled ? "Checking status..." : "Disabled",
id: (data) => `status-${data.id}`
},
infoText: (data, extension) => {
const isRemote = isRemoteWorker(extension, data);
const isCloud = data.type === 'cloud';
const isServerless = data.type === 'serverless';
if (isServerless) {
return `<span class="dist-worker-info__title">${escapeWorkerInfo(data.name)}</span>`;
}
if (isCloud) {
// For cloud workers, don't show port (it's always 443)
return `<span class="dist-worker-info__title">${data.name}</span><br><span class="dist-worker-info__meta">${data.host}</span>`;
} else if (isRemote) {
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 `<span class="dist-worker-info__title">${data.name}</span><br><span class="dist-worker-info__meta">${cudaInfo}Port ${data.port}</span>`;
}
},
controls: {
dynamic: true
},
settings: {
formType: 'worker',
id: (data) => `settings-${data.id}`,
expandedId: (data) => data?.id
},
hover: true,
expand: true,
border: 'solid'
},
blueprint: {
checkbox: {
type: 'icon',
content: '+',
width: 42,
style: `border-right: 2px dashed ${UI_COLORS.BORDER_LIGHT}; color: ${UI_COLORS.ACCENT_COLOR}; font-size: 24px; font-weight: 500;`
},
statusDot: {
color: 'transparent',
border: `1px solid ${UI_COLORS.BORDER_LIGHT}`
},
infoText: () => `<strong style="color: #aaa; font-size: 16px;">Add New Worker</strong><br><small style="color: ${UI_COLORS.BORDER_LIGHT};">[CUDA] • [Port]</small>`,
controls: {
type: 'ghost',
text: 'Configure',
style: `border: 1px solid ${UI_COLORS.BORDER_DARK}; background: transparent; color: ${UI_COLORS.BORDER_LIGHT};`
},
hover: 'placeholder',
expand: false,
border: 'dashed'
},
add: {
checkbox: {
type: 'icon',
content: '+',
width: 43,
style: `border-right: 1px dashed ${UI_COLORS.BORDER_DARK}; color: ${UI_COLORS.BORDER_LIGHT}; font-size: 18px;`
},
statusDot: {
color: 'transparent',
border: `1px solid ${UI_COLORS.BORDER_LIGHT}`
},
infoText: () => `<span style="color: ${UI_COLORS.ICON_COLOR}; font-weight: bold; font-size: 13px;">Add New Worker</span>`,
controls: null,
hover: 'placeholder',
expand: false,
border: 'dashed',
minHeight: '48px'
}
};
export class DistributedUI {
constructor() {
// UI element styles
this.styles = UI_STYLES;
}
createStatusDot(id, color = "#666", title = "Status") {
const dot = document.createElement("span");
if (id) dot.id = id;
dot.style.cssText = this.styles.statusDot + ` background-color: ${color};`;
dot.title = title;
return dot;
}
createButton(text, onClick, customStyle = "") {
const button = document.createElement("button");
button.textContent = text;
button.className = "distributed-button";
button.style.cssText = BUTTON_STYLES.base + customStyle;
if (onClick) button.onclick = onClick;
return button;
}
createButtonGroup(buttons, style = "") {
const group = document.createElement("div");
group.style.cssText = this.styles.buttonGroup + style;
buttons.forEach(button => group.appendChild(button));
return group;
}
createWorkerControls(workerId, handlers = {}) {
const controlsDiv = document.createElement("div");
controlsDiv.id = `controls-${workerId}`;
controlsDiv.style.cssText = this.styles.controlsDiv;
const buttons = [];
if (handlers.launch) {
const launchBtn = this.createButton('Launch', handlers.launch);
launchBtn.id = `launch-${workerId}`;
launchBtn.title = "Launch this worker instance";
buttons.push(launchBtn);
}
if (handlers.stop) {
const stopBtn = this.createButton('Stop', handlers.stop);
stopBtn.id = `stop-${workerId}`;
stopBtn.title = "Stop this worker instance";
buttons.push(stopBtn);
}
if (handlers.viewLog) {
const logBtn = this.createButton('View Log', handlers.viewLog);
logBtn.id = `log-${workerId}`;
logBtn.title = "View worker log file";
buttons.push(logBtn);
}
buttons.forEach(btn => controlsDiv.appendChild(btn));
return controlsDiv;
}
createFormGroup(label, value, id, type = "text", placeholder = "") {
const group = document.createElement("div");
group.style.cssText = this.styles.formGroup;
const labelEl = document.createElement("label");
labelEl.textContent = label;
labelEl.htmlFor = id;
labelEl.style.cssText = this.styles.formLabel;
const input = document.createElement("input");
input.type = type;
input.id = id;
input.value = value;
input.placeholder = placeholder;
input.classList.add('dist-form-input');
input.style.cssText = this.styles.formInput;
group.appendChild(labelEl);
group.appendChild(input);
return { group, input };
}
createInfoBox(text) {
const box = document.createElement("div");
box.classList.add('dist-info-box');
box.style.cssText = this.styles.infoBox;
box.textContent = text;
return box;
}
addHoverEffect(element, onHover, onLeave) {
element.onmouseover = onHover;
element.onmouseout = onLeave;
}
createCard(type = 'worker', options = {}) {
const card = document.createElement("div");
card.classList.add('dist-card');
switch(type) {
case 'master':
case 'worker':
card.style.cssText = this.styles.workerCard;
break;
case 'blueprint':
card.classList.add('dist-card--blueprint');
card.style.cssText = this.styles.cardBase + this.styles.cardBlueprint;
if (options.onClick) card.onclick = options.onClick;
if (options.title) card.title = options.title;
break;
case 'add':
card.classList.add('dist-card--add');
card.style.cssText = this.styles.cardBase + this.styles.cardAdd;
if (options.onClick) card.onclick = options.onClick;
if (options.title) card.title = options.title;
break;
}
if (options.onMouseEnter) {
card.addEventListener('mouseenter', options.onMouseEnter);
}
if (options.onMouseLeave) {
card.addEventListener('mouseleave', options.onMouseLeave);
}
return card;
}
createCardColumn(type = 'checkbox', options = {}) {
const column = document.createElement("div");
switch(type) {
case 'checkbox':
column.classList.add('dist-card__left-col');
column.style.cssText = this.styles.checkboxColumn;
if (options.title) column.title = options.title;
break;
case 'icon':
column.style.cssText = this.styles.columnBase + this.styles.iconColumn;
break;
case 'content':
column.style.cssText = this.styles.contentColumn;
break;
}
return column;
}
createInfoRow(options = {}) {
const row = document.createElement("div");
row.style.cssText = this.styles.infoRow;
if (options.onClick) row.onclick = options.onClick;
return row;
}
createWorkerContent() {
const content = document.createElement("div");
content.style.cssText = this.styles.workerContent;
return content;
}
createSettingsForm(fields = [], options = {}) {
const form = document.createElement("div");
form.style.cssText = this.styles.settingsForm;
fields.forEach(field => {
if (field.type === 'checkbox') {
const group = document.createElement("div");
group.style.cssText = this.styles.checkboxGroup;
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.id = field.id;
checkbox.checked = field.checked || false;
if (field.onChange) checkbox.onchange = field.onChange;
const label = document.createElement("label");
label.htmlFor = field.id;
label.textContent = field.label;
label.style.cssText = this.styles.formLabelClickable;
group.appendChild(checkbox);
group.appendChild(label);
form.appendChild(group);
} else {
const result = this.createFormGroup(field.label, field.value, field.id, field.type, field.placeholder);
if (field.groupId) result.group.id = field.groupId;
if (field.display) result.group.style.display = field.display;
form.appendChild(result.group);
}
});
if (options.buttons) {
const buttonGroup = this.createButtonGroup(options.buttons, options.buttonStyle || " margin-top: 8px;");
form.appendChild(buttonGroup);
}
return form;
}
createButtonHelper(text, onClick, style) {
return createButtonHelperFn(this, text, onClick, style);
}
updateMasterDisplay(extension) {
// Use persistent config value as fallback
const cudaDevice = extension?.config?.master?.cuda_device ?? extension?.masterCudaDevice;
// Update CUDA info if element exists
const cudaInfo = document.getElementById('master-cuda-info');
if (cudaInfo) {
const port = window.location.port || (window.location.protocol === 'https:' ? '443' : '80');
if (cudaDevice !== undefined && cudaDevice !== null) {
cudaInfo.textContent = `CUDA ${cudaDevice} • Port ${port}`;
} else {
cudaInfo.textContent = `Port ${port}`;
}
}
// Update name if changed
const nameDisplay = document.getElementById('master-name-display');
if (nameDisplay && extension?.config?.master?.name) {
nameDisplay.textContent = extension.config.master.name;
}
}
showToast(app, severity, summary, detail, life = 3000) {
if (app.extensionManager?.toast?.add) {
app.extensionManager.toast.add({ severity, summary, detail, life });
}
}
showCloudflareWarning(extension, masterHost) {
return showCloudflareWarningFn(extension, masterHost);
}
updateStatusDot(workerId, color, title, pulsing = false) {
const statusDot = document.getElementById(`status-${workerId}`);
if (!statusDot) return;
const statusClasses = [
"worker-status--online",
"worker-status--offline",
"worker-status--unknown",
"worker-status--processing",
];
statusDot.classList.remove(...statusClasses);
const colorClassMap = {
[STATUS_COLORS.ONLINE_GREEN]: "worker-status--online",
[STATUS_COLORS.OFFLINE_RED]: "worker-status--offline",
[STATUS_COLORS.DISABLED_GRAY]: "worker-status--unknown",
[STATUS_COLORS.PROCESSING_YELLOW]: "worker-status--processing",
};
const statusClass = colorClassMap[color] || "worker-status--unknown";
statusDot.classList.add(statusClass);
statusDot.style.backgroundColor = "";
statusDot.title = title;
statusDot.classList.toggle('status-pulsing', pulsing);
}
showLogModal(extension, workerId, logData, fetchLog = null) {
if (this._logModal) {
this._logModal.unmount();
this._logModal = null;
}
const worker = extension.config.workers.find(w => w.id === workerId);
const workerName = worker?.name || `Worker ${workerId}`;
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;
}
},
});
}
createWorkerSettingsForm(extension, worker) {
return createWorkerSettingsFormFn(this, extension, worker);
}
createSettingsToggle() {
const settingsRow = document.createElement("div");
settingsRow.style.cssText = this.styles.settingsToggle;
const settingsTitle = document.createElement("h4");
settingsTitle.textContent = "Settings";
settingsTitle.style.cssText = "margin: 0; font-size: 14px;";
const settingsToggle = document.createElement("span");
settingsToggle.textContent = "▶"; // Right arrow when collapsed
settingsToggle.style.cssText =
"font-size: 12px; color: var(--dist-settings-arrow, #888); transition: all 0.2s ease;";
settingsRow.appendChild(settingsToggle);
settingsRow.appendChild(settingsTitle);
return { settingsRow, settingsToggle };
}
createCheckboxOrIconColumn(config, data, extension) {
const column = this.createCardColumn('checkbox');
if (config?.type === 'icon') {
column.style.flex = `0 0 ${config.width || 44}px`;
column.innerHTML = config.content || '+';
if (config.style) {
const styles = config.style.split(';').filter(s => s.trim());
styles.forEach(style => {
const [prop, value] = style.split(':').map(s => s.trim());
if (prop && value) {
column.style[prop.replace(/-([a-z])/g, (g) => g[1].toUpperCase())] = value;
}
});
}
} else {
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.id = `gpu-${data?.id || 'master'}`;
checkbox.checked = config?.checked !== undefined ? config.checked : data?.enabled;
checkbox.disabled = config?.disabled || false;
checkbox.style.cssText = `cursor: ${config?.disabled ? 'default' : 'pointer'}; width: 16px; height: 16px;`;
if (config?.opacity) checkbox.style.opacity = config.opacity;
if (config?.title) column.title = config.title;
const isMasterToggle = config?.masterToggle && typeof extension.isMasterParticipating === 'function';
if (isMasterToggle) {
const participationEnabled = extension.isMasterParticipationEnabled();
const fallbackActive = extension.isMasterFallbackActive();
const buildTitle = (enabled, fallback) => {
if (enabled) {
return "Master participating • Click to switch to orchestrator-only";
}
if (fallback) {
return "No workers selected • Master fallback execution active";
}
return "Master orchestrator-only • Click to re-enable participation";
};
checkbox.checked = participationEnabled;
checkbox.style.pointerEvents = "none";
column.style.cursor = "pointer";
column.title = buildTitle(participationEnabled, fallbackActive);
column.onclick = async (event) => {
if (event) {
event.stopPropagation();
event.preventDefault();
}
const nextState = !extension.isMasterParticipationEnabled();
const nextFallback = !nextState && extension.enabledWorkers.length === 0;
checkbox.checked = nextState;
column.title = buildTitle(nextState, nextFallback);
await extension.updateMasterParticipation(nextState);
};
} else if (config?.enabled && !config?.disabled && data?.id) {
checkbox.style.pointerEvents = "none";
column.style.cursor = "pointer";
column.onclick = async () => {
checkbox.checked = !checkbox.checked;
await extension.updateWorkerEnabled(data.id, checkbox.checked);
};
}
column.appendChild(checkbox);
}
return column;
}
createStatusDotHelper(config, data, extension) {
let color = config.color || "#666";
let title = config.title || "Status";
let id = config.id;
if (typeof config.initialColor === 'function') {
color = config.initialColor(data, extension);
}
if (typeof config.initialTitle === 'function') {
title = config.initialTitle(data, extension);
}
if (typeof config.id === 'function') {
id = config.id(data);
}
const dot = this.createStatusDot(id, color, title);
if (config.border) {
dot.style.border = config.border;
}
if (config.pulsing && (typeof config.pulsing !== 'function' || config.pulsing(data))) {
dot.classList.add('status-pulsing');
}
return dot;
}
createSettingsToggleHelper(expandedId, extension) {
const arrow = document.createElement("span");
arrow.className = "settings-arrow";
arrow.innerHTML = "▶";
arrow.style.cssText = this.styles.settingsArrow;
const isExpanded = typeof expandedId === 'function' ?
extension.state.isWorkerExpanded(expandedId(extension)) :
(expandedId === 'master' ? false : extension.state.isWorkerExpanded(expandedId));
if (isExpanded) {
arrow.style.transform = "rotate(90deg)";
}
return arrow;
}
createControlsSection(config, data, extension, isRemote) {
if (!config) return null;
const controlsDiv = document.createElement("div");
controlsDiv.id = `controls-${data?.id || 'master'}`;
controlsDiv.style.cssText = this.styles.controlsDiv;
// Always create a wrapper div for consistent layout
const controlsWrapper = document.createElement("div");
controlsWrapper.style.cssText = this.styles.controlsWrapper;
if (config.type === 'master') {
const participationEnabled = extension.isMasterParticipationEnabled();
const fallbackActive = extension.isMasterFallbackActive();
let message;
const badge = document.createElement("div");
badge.classList.add("dist-info-box", "master-info-badge");
badge.style.cssText = this.styles.infoBox;
if (fallbackActive) {
message = "No workers selected. Master fallback execution active.";
badge.textContent = message;
badge.classList.add("master-info-badge--fallback");
} else if (!participationEnabled) {
message = "Master disabled: running as orchestrator only";
badge.textContent = message;
badge.classList.add("master-info-badge--delegate");
} else {
message = "Master participating in workflows";
badge.textContent = message;
}
controlsWrapper.appendChild(badge);
} else if (config.dynamic && data) {
if (data.type === 'serverless') {
// Match the master/remote footer without exposing direct-worker actions.
controlsWrapper.appendChild(this.createInfoBox("Runpod Serverless endpoint"));
} else 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;
logBtn.classList.add("btn--log");
logBtn.title = "View remote worker log";
controlsWrapper.appendChild(logBtn);
} else {
const controls = this.createWorkerControls(data.id, {
launch: () => launchWorker(extension, data.id),
stop: () => stopWorker(extension, data.id),
viewLog: () => viewWorkerLog(extension, data.id)
});
const launchBtn = controls.querySelector(`#launch-${data.id}`);
const stopBtn = controls.querySelector(`#stop-${data.id}`);
const logBtn = controls.querySelector(`#log-${data.id}`);
launchBtn.style.cssText = BUTTON_STYLES.base + BUTTON_STYLES.workerControl;
launchBtn.classList.add("btn--launch");
launchBtn.title = "Launch worker (runs in background with logging)";
stopBtn.style.cssText = BUTTON_STYLES.base + BUTTON_STYLES.workerControl + BUTTON_STYLES.hidden;
stopBtn.classList.add("btn--stop");
stopBtn.title = "Stop worker";
logBtn.style.cssText = BUTTON_STYLES.base + BUTTON_STYLES.workerControl + BUTTON_STYLES.hidden;
logBtn.classList.add("btn--log");
while (controls.firstChild) {
controlsWrapper.appendChild(controls.firstChild);
}
}
} else if (config.type === 'info') {
const infoBtn = this.createButton(config.text, null, config.style || "");
infoBtn.style.cssText = BUTTON_STYLES.base + BUTTON_STYLES.workerControl + (config.style || BUTTON_STYLES.info) + " cursor: default;";
infoBtn.disabled = true;
controlsWrapper.appendChild(infoBtn);
} else if (config.type === 'ghost') {
const ghostBtn = document.createElement("button");
ghostBtn.style.cssText = `flex: 1; padding: 5px 14px; font-size: 11px; font-weight: 500; border-radius: 4px; cursor: default; ${config.style || ""}`;
ghostBtn.textContent = config.text;
ghostBtn.disabled = true;
controlsWrapper.appendChild(ghostBtn);
}
controlsDiv.appendChild(controlsWrapper);
return controlsDiv;
}
createSettingsSection(config, data, extension) {
const settingsDiv = document.createElement("div");
const settingsId = typeof config.id === 'function' ? config.id(data) : config.id;
settingsDiv.id = settingsId;
settingsDiv.className = "worker-settings";
const expandedId = typeof config.expandedId === 'function' ? config.expandedId(data) : config.expandedId;
const isExpanded = expandedId === 'master-settings' ? false : extension.state.isWorkerExpanded(expandedId);
settingsDiv.style.cssText = this.styles.workerSettings;
if (isExpanded) {
settingsDiv.classList.add("expanded");
settingsDiv.style.padding = "12px";
settingsDiv.style.marginTop = "8px";
settingsDiv.style.marginBottom = "8px";
}
let settingsForm;
if (config.formType === 'master') {
settingsForm = this.createMasterSettingsForm(extension, data);
} else if (config.formType === 'worker') {
settingsForm = this.createWorkerSettingsForm(extension, data);
}
if (settingsForm) {
settingsDiv.appendChild(settingsForm);
}
return settingsDiv;
}
createMasterSettingsForm(extension, data) {
const settingsForm = document.createElement("div");
settingsForm.style.cssText = "display: flex; flex-direction: column; gap: 8px;";
const nameResult = this.createFormGroup("Name:", extension.config?.master?.name || "Master", "master-name");
settingsForm.appendChild(nameResult.group);
const hostResult = this.createFormGroup("Host:", extension.config?.master?.host || "", "master-host", "text", "Auto-detect if empty");
settingsForm.appendChild(hostResult.group);
// Cloudflare tunnel toggle (simple button inside master settings)
const tunnelBtn = this.createButton("Enable Cloudflare Tunnel", (e) => extension.handleTunnelToggle(e.target));
tunnelBtn.id = "cloudflare-tunnel-button";
tunnelBtn.style.cssText = BUTTON_STYLES.base + " margin: 4px 0 -5px 0;";
tunnelBtn.classList.add("tunnel-button", "tunnel-button--enable");
settingsForm.appendChild(tunnelBtn);
extension.tunnelElements = { button: tunnelBtn };
extension.updateTunnelUIElements();
const saveBtn = this.createButton("Save", async () => {
const nameInput = document.getElementById('master-name');
const hostInput = document.getElementById('master-host');
if (!extension.config.master) extension.config.master = {};
extension.config.master.name = nameInput.value.trim() || "Master";
const hostValue = hostInput.value.trim();
await extension.api.updateMaster({
host: hostValue,
name: extension.config.master.name
});
// Reload config to refresh any updated values
await extension.loadConfig();
// If host was emptied, trigger auto-detection
if (!hostValue) {
extension.log("Host field cleared, triggering IP auto-detection", "debug");
await extension.detectMasterIP();
// Reload config again to get the auto-detected IP
await extension.loadConfig();
// Update the input field with the detected IP
document.getElementById('master-host').value = extension.config?.master?.host || "";
}
document.getElementById('master-name-display').textContent = extension.config.master.name;
this.updateMasterDisplay(extension);
// Show toast notification
if (extension.app?.extensionManager?.toast) {
const message = !hostValue ?
"Master settings saved and IP auto-detected" :
"Master settings saved successfully";
extension.app.extensionManager.toast.add({
severity: "success",
summary: "Master Updated",
detail: message,
life: 3000
});
}
saveBtn.textContent = "Saved!";
setTimeout(() => { saveBtn.textContent = "Save"; }, TIMEOUTS.FLASH_LONG);
}, "background-color: #4a7c4a;");
saveBtn.style.cssText = BUTTON_STYLES.base + BUTTON_STYLES.success;
const cancelBtn = this.createButton("Cancel", () => {
document.getElementById('master-name').value = extension.config?.master?.name || "Master";
document.getElementById('master-host').value = extension.config?.master?.host || "";
}, "background-color: #555;");
cancelBtn.style.cssText = BUTTON_STYLES.base + BUTTON_STYLES.cancel;
const buttonGroup = this.createButtonGroup([saveBtn, cancelBtn], " margin-top: 8px;");
settingsForm.appendChild(buttonGroup);
return settingsForm;
}
addPlaceholderHover(card, leftColumn, entityType) {
const cardTypeClass = entityType === 'blueprint' ? 'placeholder-card--blueprint' : 'placeholder-card--add';
const columnTypeClass = entityType === 'blueprint' ? 'placeholder-column--blueprint' : 'placeholder-column--add';
card.classList.add('placeholder-card', cardTypeClass);
leftColumn.classList.add('placeholder-column', columnTypeClass);
card.onmouseover = () => {
card.classList.add('is-hovered');
leftColumn.classList.add('is-hovered');
};
card.onmouseout = () => {
card.classList.remove('is-hovered');
leftColumn.classList.remove('is-hovered');
};
}
renderEntityCard(entityType, data, extension) {
return renderEntityCardFn(this, cardConfigs, entityType, data, extension);
}
}