Merge feat/runpod-serverless-workers into feat/dynamic-image-list-queue
This commit is contained in:
+16
-1
@@ -15,7 +15,8 @@ import { parseHostInput, getMasterUrl as buildMasterUrl } from './urlUtils.js';
|
||||
|
||||
const WORKERS_CHANGED_EVENT = "distributed:workers-changed";
|
||||
|
||||
class DistributedExtension {
|
||||
// Exported for tests; ComfyUI only uses the registerExtension side effect below.
|
||||
export class DistributedExtension {
|
||||
constructor() {
|
||||
this.config = null;
|
||||
this.originalQueuePrompt = api.queuePrompt.bind(api);
|
||||
@@ -196,6 +197,7 @@ class DistributedExtension {
|
||||
|
||||
async updateWorkerEnabled(workerId, enabled) {
|
||||
const worker = this.config.workers.find(w => w.id === workerId);
|
||||
const previousEnabled = worker ? Boolean(worker.enabled) : !enabled;
|
||||
if (worker) {
|
||||
worker.enabled = enabled;
|
||||
this.state.updateWorker(workerId, { enabled });
|
||||
@@ -219,6 +221,19 @@ class DistributedExtension {
|
||||
await this.api.updateWorker(workerId, { enabled });
|
||||
} catch (error) {
|
||||
this.log("Error updating worker: " + error.message, "error");
|
||||
// The backend kept the old state (e.g. a serverless worker without a
|
||||
// saved API key); show that instead of a toggle that did not stick.
|
||||
if (worker) {
|
||||
worker.enabled = previousEnabled;
|
||||
this.state.updateWorker(workerId, { enabled: previousEnabled });
|
||||
this._emitWorkersChanged();
|
||||
}
|
||||
this.app?.extensionManager?.toast?.add({
|
||||
severity: "error",
|
||||
summary: enabled ? "Could not enable worker" : "Could not disable worker",
|
||||
detail: error.message,
|
||||
life: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.panelElement) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { createWorkerSettingsForm } from "../ui/settingsForm.js";
|
||||
import { DistributedUI } from "../ui.js";
|
||||
import { checkWorkerStatus, formatServerlessStatus, getWorkerUrl, launchWorker, stopWorker, viewWorkerLog } from "../workerLifecycle.js";
|
||||
import { handleClearMemory, handleInterruptWorkers } from "../workerUtils.js";
|
||||
import { isRemoteWorker, saveWorkerSettings, testServerlessWorker } from "../workerSettings.js";
|
||||
import { isRemoteWorker, normalizeRunpodEndpointId, saveWorkerSettings, testServerlessWorker } from "../workerSettings.js";
|
||||
import { performPreflightCheck } from "../executionUtils.js";
|
||||
|
||||
function createDocumentMock() {
|
||||
@@ -125,12 +125,14 @@ describe("Runpod serverless worker panel", () => {
|
||||
expect(elements.get(`${field}-group-saved`).style.display).toBe("none");
|
||||
}
|
||||
expect(elements.get("runpod-text-saved").style.display).toBe("none");
|
||||
for (const field of ["endpoint-id", "api-key", "clear-api-key", "jobs-per-run", "concurrency", "timeout-seconds", "serverless-test"]) {
|
||||
for (const field of ["endpoint-id", "api-key", "clear-api-key", "jobs-per-run", "timeout-seconds", "serverless-test"]) {
|
||||
expect(elements.get(`${field}-group-saved`).style.display).toBe("flex");
|
||||
}
|
||||
// The separate concurrency setting was removed; a legacy value is ignored.
|
||||
expect(elements.has("concurrency-saved")).toBe(false);
|
||||
expect(elements.has("concurrency-group-saved")).toBe(false);
|
||||
expect(elements.get("endpoint-id-saved").value).toBe("ep_123");
|
||||
expect(elements.get("jobs-per-run-saved").value).toBe("2");
|
||||
expect(elements.get("concurrency-saved").value).toBe("1");
|
||||
expect(elements.get("timeout-seconds-saved").value).toBe("900");
|
||||
expect(elements.get("api-key-saved").value).toBe("");
|
||||
expect(elements.get("api-key-saved").placeholder).toMatch(/leave blank/i);
|
||||
@@ -170,10 +172,11 @@ describe("Runpod serverless worker panel", () => {
|
||||
for (const id of ["host-group-w1", "port-group-w1", "cuda-group-w1", "args-group-w1"]) {
|
||||
expect(elements.get(id).style.display).toBe("none");
|
||||
}
|
||||
for (const id of ["endpoint-id-w1", "api-key-w1", "jobs-per-run-w1", "concurrency-w1", "timeout-seconds-w1"]) {
|
||||
for (const id of ["endpoint-id-w1", "api-key-w1", "jobs-per-run-w1", "timeout-seconds-w1"]) {
|
||||
expect(elements.has(id)).toBe(true);
|
||||
}
|
||||
for (const id of ["endpoint-id-group-w1", "api-key-group-w1", "clear-api-key-group-w1", "jobs-per-run-group-w1", "concurrency-group-w1", "timeout-seconds-group-w1", "serverless-test-group-w1"]) {
|
||||
expect(elements.has("concurrency-w1")).toBe(false);
|
||||
for (const id of ["endpoint-id-group-w1", "api-key-group-w1", "clear-api-key-group-w1", "jobs-per-run-group-w1", "timeout-seconds-group-w1", "serverless-test-group-w1"]) {
|
||||
expect(elements.get(id).style.display).toBe("flex");
|
||||
}
|
||||
expect(elements.has("serverless-help-w1")).toBe(false);
|
||||
@@ -183,15 +186,32 @@ describe("Runpod serverless worker panel", () => {
|
||||
expect(elements.get("jobs-per-run-w1").value).toBe("1");
|
||||
expect(elements.get("jobs-per-run-w1").min).toBe("1");
|
||||
expect(elements.get("jobs-per-run-w1").max).toBe("16");
|
||||
expect(elements.get("concurrency-w1").value).toBe("1");
|
||||
expect(elements.get("concurrency-w1").min).toBe("1");
|
||||
expect(elements.get("concurrency-w1").max).toBe("16");
|
||||
expect(elements.get("jobs-per-run-w1").title).toBe(
|
||||
"Separate Runpod jobs submitted each run, all at once. Runpod's max workers decides how many GPUs actually run them.",
|
||||
);
|
||||
expect(elements.get("timeout-seconds-w1").value).toBe("1800");
|
||||
expect(elements.get("timeout-seconds-w1").min).toBe("30");
|
||||
expect(elements.get("timeout-seconds-w1").max).toBe("86400");
|
||||
expect(form.children.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("disables Clear saved API key while the worker is enabled and explains why", () => {
|
||||
vi.spyOn(document, "getElementById").mockReturnValue(null);
|
||||
const base = { name: "Runpod", type: "serverless", endpoint_id: "ep_123" };
|
||||
createWorkerSettingsForm(createFormUi(), {}, { ...base, id: "on", enabled: true, api_key_configured: true });
|
||||
createWorkerSettingsForm(createFormUi(), {}, { ...base, id: "off", enabled: false, api_key_configured: true });
|
||||
createWorkerSettingsForm(createFormUi(), {}, { ...base, id: "nokey", enabled: false, api_key_configured: false });
|
||||
|
||||
expect(elements.get("clear-api-key-on").disabled).toBe(true);
|
||||
expect(elements.get("clear-api-key-group-on").title).toMatch(/turn this worker off/i);
|
||||
expect(elements.get("clear-api-key-off").disabled).toBe(false);
|
||||
expect(elements.get("clear-api-key-nokey").disabled).toBe(true);
|
||||
expect(elements.get("clear-api-key-group-nokey").title).toMatch(/no api key is saved/i);
|
||||
for (const id of ["on", "off", "nokey"]) {
|
||||
expect(elements.get(`clear-api-key-group-${id}`).title).not.toMatch(/pool/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("saves fixed Runpod settings without enabling or retaining the API key", async () => {
|
||||
const worker = { id: "w2", name: "Old", type: "remote", enabled: false, api_key_configured: false };
|
||||
const extension = makeSaveExtension(worker);
|
||||
@@ -201,14 +221,13 @@ describe("Runpod serverless worker panel", () => {
|
||||
elements.set("api-key-w2", { value: "secret-token" });
|
||||
elements.set("clear-api-key-w2", { checked: false });
|
||||
elements.set("jobs-per-run-w2", { value: "4" });
|
||||
elements.set("concurrency-w2", { value: "3" });
|
||||
elements.set("timeout-seconds-w2", { value: "2400" });
|
||||
|
||||
await saveWorkerSettings(extension, "w2");
|
||||
|
||||
expect(extension.api.updateWorker).toHaveBeenCalledWith("w2", {
|
||||
name: "Runpod worker", type: "serverless", provider: "runpod", endpoint_id: "ep_123",
|
||||
api_key: "secret-token", jobs_per_run: 4, concurrency: 3, timeout_seconds: 2400, enabled: false,
|
||||
api_key: "secret-token", jobs_per_run: 4, timeout_seconds: 2400, enabled: false,
|
||||
});
|
||||
expect(worker).toMatchObject({ type: "serverless", provider: "runpod", enabled: false, api_key_configured: true });
|
||||
expect(worker).not.toHaveProperty("api_key");
|
||||
@@ -218,6 +237,104 @@ describe("Runpod serverless worker panel", () => {
|
||||
expect(JSON.stringify(extension.state.updateWorker.mock.calls)).not.toContain("secret-token");
|
||||
});
|
||||
|
||||
it("labels the single parallelism setting Images per run and drops a legacy concurrency value", async () => {
|
||||
const worker = { id: "legacy", name: "Runpod", type: "serverless", provider: "runpod", enabled: false,
|
||||
api_key_configured: true, jobs_per_run: 1, concurrency: 1 };
|
||||
const extension = makeSaveExtension(worker);
|
||||
elements.set("name-legacy", { value: "Runpod" });
|
||||
elements.set("worker-type-legacy", { value: "serverless" });
|
||||
elements.set("endpoint-id-legacy", { value: "ep_123" });
|
||||
elements.set("api-key-legacy", { value: "" });
|
||||
elements.set("clear-api-key-legacy", { checked: false });
|
||||
elements.set("jobs-per-run-legacy", { value: "2" });
|
||||
elements.set("timeout-seconds-legacy", { value: "1800" });
|
||||
|
||||
await saveWorkerSettings(extension, "legacy");
|
||||
|
||||
const payload = extension.api.updateWorker.mock.calls[0][1];
|
||||
expect(payload.jobs_per_run).toBe(2);
|
||||
expect(payload).not.toHaveProperty("concurrency");
|
||||
expect(worker.jobs_per_run).toBe(2);
|
||||
expect(worker).not.toHaveProperty("concurrency");
|
||||
|
||||
elements.set("jobs-per-run-legacy", { value: "17" });
|
||||
await saveWorkerSettings(extension, "legacy");
|
||||
expect(extension.api.updateWorker).toHaveBeenCalledTimes(1);
|
||||
expect(extension.app.extensionManager.toast.add).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ detail: "Images per run must be a whole number from 1 to 16" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("trims a pasted Runpod URL to the endpoint ID and explains other invalid IDs", async () => {
|
||||
expect(normalizeRunpodEndpointId(" abc123xyz ")).toBe("abc123xyz");
|
||||
expect(normalizeRunpodEndpointId("https://api.runpod.ai/v2/abc123xyz/run")).toBe("abc123xyz");
|
||||
expect(normalizeRunpodEndpointId("api.runpod.ai/v2/abc_12-3")).toBe("abc_12-3");
|
||||
expect(normalizeRunpodEndpointId("https://www.runpod.io/console/serverless/user/endpoint/abc")).toBe(
|
||||
"https://www.runpod.io/console/serverless/user/endpoint/abc",
|
||||
);
|
||||
|
||||
const worker = { id: "url", name: "Runpod", type: "serverless", provider: "runpod", enabled: false, api_key_configured: true };
|
||||
const extension = makeSaveExtension(worker);
|
||||
elements.set("name-url", { value: "Runpod" });
|
||||
elements.set("worker-type-url", { value: "serverless" });
|
||||
elements.set("endpoint-id-url", { value: "https://api.runpod.ai/v2/abc123xyz/runsync" });
|
||||
elements.set("api-key-url", { value: "" });
|
||||
elements.set("clear-api-key-url", { checked: false });
|
||||
elements.set("jobs-per-run-url", { value: "1" });
|
||||
elements.set("timeout-seconds-url", { value: "1800" });
|
||||
await saveWorkerSettings(extension, "url");
|
||||
expect(extension.api.updateWorker.mock.calls[0][1].endpoint_id).toBe("abc123xyz");
|
||||
|
||||
elements.set("endpoint-id-url", { value: "https://www.runpod.io/console/serverless/endpoint/abc" });
|
||||
await saveWorkerSettings(extension, "url");
|
||||
expect(extension.api.updateWorker).toHaveBeenCalledTimes(1);
|
||||
expect(extension.app.extensionManager.toast.add).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ detail: expect.stringMatching(/just the Runpod endpoint ID/) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("asks before a type change deletes the saved API key, and cancels cleanly", async () => {
|
||||
const worker = { id: "conv", name: "Runpod", type: "serverless", provider: "runpod", enabled: false, api_key_configured: true };
|
||||
const extension = makeSaveExtension(worker);
|
||||
elements.set("name-conv", { value: "Runpod" });
|
||||
elements.set("worker-type-conv", { value: "remote" });
|
||||
elements.set("host-conv", { value: "192.168.1.20" });
|
||||
elements.set("port-conv", { value: "8188" });
|
||||
const confirmSpy = vi.fn().mockReturnValue(false);
|
||||
globalThis.confirm = confirmSpy;
|
||||
try {
|
||||
await saveWorkerSettings(extension, "conv");
|
||||
expect(confirmSpy).toHaveBeenCalledWith(expect.stringMatching(/saved Runpod API key will be deleted/));
|
||||
expect(extension.api.updateWorker).not.toHaveBeenCalled();
|
||||
expect(worker.type).toBe("serverless");
|
||||
|
||||
confirmSpy.mockReturnValue(true);
|
||||
await saveWorkerSettings(extension, "conv");
|
||||
expect(extension.api.updateWorker).toHaveBeenCalledTimes(1);
|
||||
expect(extension.api.updateWorker.mock.calls[0][1].type).toBe("remote");
|
||||
} finally {
|
||||
delete globalThis.confirm;
|
||||
}
|
||||
});
|
||||
|
||||
it("does not ask for confirmation when a serverless worker without a key changes type", async () => {
|
||||
const worker = { id: "nokey2", name: "Runpod", type: "serverless", provider: "runpod", enabled: false, api_key_configured: false };
|
||||
const extension = makeSaveExtension(worker);
|
||||
elements.set("name-nokey2", { value: "Runpod" });
|
||||
elements.set("worker-type-nokey2", { value: "remote" });
|
||||
elements.set("host-nokey2", { value: "192.168.1.20" });
|
||||
elements.set("port-nokey2", { value: "8188" });
|
||||
const confirmSpy = vi.fn().mockReturnValue(false);
|
||||
globalThis.confirm = confirmSpy;
|
||||
try {
|
||||
await saveWorkerSettings(extension, "nokey2");
|
||||
expect(confirmSpy).not.toHaveBeenCalled();
|
||||
expect(extension.api.updateWorker).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
delete globalThis.confirm;
|
||||
}
|
||||
});
|
||||
|
||||
it("omits a blank API key so the saved key is preserved", async () => {
|
||||
const worker = { id: "w-blank", name: "Runpod", type: "serverless", provider: "runpod", enabled: true, api_key_configured: true };
|
||||
const extension = makeSaveExtension(worker);
|
||||
@@ -227,7 +344,6 @@ describe("Runpod serverless worker panel", () => {
|
||||
elements.set("api-key-w-blank", { value: "" });
|
||||
elements.set("clear-api-key-w-blank", { checked: false });
|
||||
elements.set("jobs-per-run-w-blank", { value: "1" });
|
||||
elements.set("concurrency-w-blank", { value: "1" });
|
||||
elements.set("timeout-seconds-w-blank", { value: "1800" });
|
||||
|
||||
await saveWorkerSettings(extension, "w-blank");
|
||||
@@ -248,14 +364,13 @@ describe("Runpod serverless worker panel", () => {
|
||||
elements.set("api-key-w3", { value: "" });
|
||||
elements.set("clear-api-key-w3", { checked: true });
|
||||
elements.set("jobs-per-run-w3", { value: "1" });
|
||||
elements.set("concurrency-w3", { value: "1" });
|
||||
elements.set("timeout-seconds-w3", { value: "1800" });
|
||||
|
||||
await saveWorkerSettings(extension, "w3");
|
||||
|
||||
expect(extension.api.updateWorker).toHaveBeenCalledWith("w3", {
|
||||
name: "Runpod", type: "serverless", provider: "runpod", endpoint_id: "ep_123",
|
||||
clear_api_key: true, jobs_per_run: 1, concurrency: 1, timeout_seconds: 1800, enabled: true,
|
||||
clear_api_key: true, jobs_per_run: 1, timeout_seconds: 1800, enabled: true,
|
||||
});
|
||||
expect(worker.api_key_configured).toBe(false);
|
||||
expect(worker).not.toHaveProperty("api_key");
|
||||
@@ -300,7 +415,7 @@ describe("Runpod serverless worker panel", () => {
|
||||
expect(elements.get("serverless-status-compact").textContent).toBe("Queued");
|
||||
expect(elements.get("endpoint-id-compact").value).toBe("ep_private_id");
|
||||
expect(Number(elements.get("jobs-per-run-compact").value)).toBe(1);
|
||||
expect(Number(elements.get("concurrency-compact").value)).toBe(1);
|
||||
expect(elements.has("concurrency-compact")).toBe(false);
|
||||
const box = elements.get("controls-compact").children[0].children[0];
|
||||
expect(box.textContent).toBe("Runpod Serverless endpoint");
|
||||
expect(box.classList.values.has("dist-info-box")).toBe(true);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../scripts/app.js", () => ({ app: { registerExtension: vi.fn() } }), { virtual: true });
|
||||
vi.mock("../../scripts/api.js", () => ({ api: { clientId: "test-client", queuePrompt: vi.fn() } }), { virtual: true });
|
||||
|
||||
import { DistributedExtension } from "../main.js";
|
||||
|
||||
function makeExtension(worker, updateWorker) {
|
||||
return {
|
||||
config: { workers: [worker] },
|
||||
state: { updateWorker: vi.fn() },
|
||||
api: { updateWorker },
|
||||
app: { extensionManager: { toast: { add: vi.fn() } } },
|
||||
ui: { updateStatusDot: vi.fn() },
|
||||
log: vi.fn(),
|
||||
_emitWorkersChanged: vi.fn(),
|
||||
panelElement: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("worker enable toggle", () => {
|
||||
let originalDocument;
|
||||
beforeEach(() => {
|
||||
originalDocument = globalThis.document;
|
||||
globalThis.document = { getElementById: () => null };
|
||||
});
|
||||
afterEach(() => {
|
||||
globalThis.document = originalDocument;
|
||||
});
|
||||
|
||||
it("reverts the toggle and explains why when the backend rejects the change", async () => {
|
||||
const worker = { id: "w1", type: "serverless", enabled: false };
|
||||
const extension = makeExtension(worker, vi.fn().mockRejectedValue(new Error("Save an API key before enabling this worker")));
|
||||
|
||||
await DistributedExtension.prototype.updateWorkerEnabled.call(extension, "w1", true);
|
||||
|
||||
expect(worker.enabled).toBe(false);
|
||||
expect(extension.state.updateWorker).toHaveBeenLastCalledWith("w1", { enabled: false });
|
||||
expect(extension.app.extensionManager.toast.add).toHaveBeenCalledWith(expect.objectContaining({
|
||||
severity: "error", summary: "Could not enable worker", detail: "Save an API key before enabling this worker",
|
||||
}));
|
||||
});
|
||||
|
||||
it("keeps the new state when the backend accepts it", async () => {
|
||||
const worker = { id: "w2", type: "serverless", enabled: false };
|
||||
const extension = makeExtension(worker, vi.fn().mockResolvedValue({ status: "success" }));
|
||||
|
||||
await DistributedExtension.prototype.updateWorkerEnabled.call(extension, "w2", true);
|
||||
|
||||
expect(worker.enabled).toBe(true);
|
||||
expect(extension.state.updateWorker).toHaveBeenLastCalledWith("w2", { enabled: true });
|
||||
expect(extension.app.extensionManager.toast.add).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+17
-13
@@ -1,6 +1,9 @@
|
||||
import { BUTTON_STYLES } from '../constants.js';
|
||||
import { cancelWorkerSettings, deleteWorker, isRemoteWorker, saveWorkerSettings, testServerlessWorker } from '../workerSettings.js';
|
||||
|
||||
export const IMAGES_PER_RUN_TOOLTIP =
|
||||
"Separate Runpod jobs submitted each run, all at once. Runpod's max workers decides how many GPUs actually run them.";
|
||||
|
||||
export function createWorkerSettingsForm(ui, extension, worker) {
|
||||
const form = document.createElement("div");
|
||||
form.style.cssText = "display: flex; flex-direction: column; gap: 8px;";
|
||||
@@ -59,7 +62,6 @@ export function createWorkerSettingsForm(ui, extension, worker) {
|
||||
apiKeyGroup.group,
|
||||
clearApiKeyGroup,
|
||||
jobsPerRunGroup.group,
|
||||
concurrencyGroup.group,
|
||||
timeoutGroup.group,
|
||||
testServerlessGroup,
|
||||
];
|
||||
@@ -150,7 +152,17 @@ export function createWorkerSettingsForm(ui, extension, worker) {
|
||||
clearApiKeyInput.id = `clear-api-key-${worker.id}`;
|
||||
const clearApiKeyLabel = document.createElement("label");
|
||||
clearApiKeyLabel.htmlFor = clearApiKeyInput.id;
|
||||
clearApiKeyLabel.textContent = "Clear saved API key (disable pool first)";
|
||||
clearApiKeyLabel.textContent = "Clear saved API key";
|
||||
// The backend rejects clearing the key of an enabled worker; say so up front.
|
||||
// The form is rebuilt when the enable toggle changes, so this stays current.
|
||||
const clearApiKeyBlockedReason = !worker.api_key_configured
|
||||
? "No API key is saved for this worker."
|
||||
: worker.enabled
|
||||
? "Turn this worker off before clearing its API key. An enabled serverless worker needs a saved key."
|
||||
: "";
|
||||
clearApiKeyInput.disabled = Boolean(clearApiKeyBlockedReason);
|
||||
clearApiKeyGroup.title = clearApiKeyBlockedReason || "Remove the saved Runpod API key when you save.";
|
||||
clearApiKeyLabel.style.cssText = clearApiKeyBlockedReason ? "opacity: 0.5; cursor: not-allowed;" : "cursor: pointer;";
|
||||
clearApiKeyGroup.appendChild(clearApiKeyInput);
|
||||
clearApiKeyGroup.appendChild(clearApiKeyLabel);
|
||||
clearApiKeyInput.onchange = () => {
|
||||
@@ -159,24 +171,16 @@ export function createWorkerSettingsForm(ui, extension, worker) {
|
||||
};
|
||||
form.appendChild(clearApiKeyGroup);
|
||||
|
||||
const jobsPerRunGroup = ui.createFormGroup("Jobs per run:", worker.jobs_per_run ?? 1, `jobs-per-run-${worker.id}`, "number");
|
||||
const jobsPerRunGroup = ui.createFormGroup("Images per run (1–16):", worker.jobs_per_run ?? 1, `jobs-per-run-${worker.id}`, "number");
|
||||
jobsPerRunGroup.group.id = `jobs-per-run-group-${worker.id}`;
|
||||
jobsPerRunGroup.input.min = "1";
|
||||
jobsPerRunGroup.input.max = "16";
|
||||
jobsPerRunGroup.input.step = "1";
|
||||
jobsPerRunGroup.input.title = "Maximum jobs submitted in one run";
|
||||
jobsPerRunGroup.input.title = IMAGES_PER_RUN_TOOLTIP;
|
||||
jobsPerRunGroup.group.title = IMAGES_PER_RUN_TOOLTIP;
|
||||
jobsPerRunGroup.group.style.display = "none";
|
||||
form.appendChild(jobsPerRunGroup.group);
|
||||
|
||||
const concurrencyGroup = ui.createFormGroup("Concurrent jobs (slots):", worker.concurrency ?? 1, `concurrency-${worker.id}`, "number");
|
||||
concurrencyGroup.group.id = `concurrency-group-${worker.id}`;
|
||||
concurrencyGroup.input.min = "1";
|
||||
concurrencyGroup.input.max = "16";
|
||||
concurrencyGroup.input.step = "1";
|
||||
concurrencyGroup.input.title = "Maximum concurrent jobs per serverless worker";
|
||||
concurrencyGroup.group.style.display = "none";
|
||||
form.appendChild(concurrencyGroup.group);
|
||||
|
||||
const timeoutGroup = ui.createFormGroup("Timeout (seconds):", worker.timeout_seconds ?? 1800, `timeout-seconds-${worker.id}`, "number");
|
||||
timeoutGroup.group.id = `timeout-seconds-group-${worker.id}`;
|
||||
timeoutGroup.input.min = "30";
|
||||
|
||||
+31
-10
@@ -37,6 +37,19 @@ export function isCloudWorker(extension, worker) {
|
||||
return worker.type === "cloud";
|
||||
}
|
||||
|
||||
// Mirrors the backend rule in utils/serverless_config.py.
|
||||
const RUNPOD_ENDPOINT_ID_RE = /^[A-Za-z0-9_-]{1,200}$/;
|
||||
|
||||
/**
|
||||
* Accept a pasted Runpod API URL (https://api.runpod.ai/v2/<id>/...) and keep
|
||||
* only the endpoint ID; anything else is returned trimmed for validation.
|
||||
*/
|
||||
export function normalizeRunpodEndpointId(value) {
|
||||
const text = String(value || "").trim();
|
||||
const match = text.match(/^(?:https?:\/\/)?api\.runpod\.ai\/v2\/([A-Za-z0-9_-]{1,200})(?:[/?#].*)?$/i);
|
||||
return match ? match[1] : text;
|
||||
}
|
||||
|
||||
function showServerlessValidationError(extension, detail) {
|
||||
extension.app.extensionManager.toast.add({
|
||||
severity: "error",
|
||||
@@ -47,11 +60,10 @@ function showServerlessValidationError(extension, detail) {
|
||||
}
|
||||
|
||||
async function saveServerlessWorkerSettings(extension, worker, workerId, name) {
|
||||
const endpointId = String(document.getElementById(`endpoint-id-${workerId}`).value || "").trim();
|
||||
const endpointId = normalizeRunpodEndpointId(document.getElementById(`endpoint-id-${workerId}`).value);
|
||||
const apiKey = document.getElementById(`api-key-${workerId}`).value || "";
|
||||
const clearApiKey = Boolean(document.getElementById(`clear-api-key-${workerId}`).checked);
|
||||
const jobsPerRun = Number(document.getElementById(`jobs-per-run-${workerId}`).value);
|
||||
const concurrency = Number(document.getElementById(`concurrency-${workerId}`).value);
|
||||
const timeoutSeconds = Number(document.getElementById(`timeout-seconds-${workerId}`).value);
|
||||
|
||||
if (!name.trim()) {
|
||||
@@ -62,12 +74,15 @@ async function saveServerlessWorkerSettings(extension, worker, workerId, name) {
|
||||
showServerlessValidationError(extension, "Endpoint ID is required for Runpod serverless workers");
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(jobsPerRun) || jobsPerRun < 1 || jobsPerRun > 16) {
|
||||
showServerlessValidationError(extension, "Jobs per run must be an integer between 1 and 16");
|
||||
if (!RUNPOD_ENDPOINT_ID_RE.test(endpointId)) {
|
||||
showServerlessValidationError(
|
||||
extension,
|
||||
"Enter just the Runpod endpoint ID (for example abc123xyz), not a URL. It is shown on the endpoint's page in the Runpod console.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 16) {
|
||||
showServerlessValidationError(extension, "Concurrency must be an integer between 1 and 16");
|
||||
if (!Number.isInteger(jobsPerRun) || jobsPerRun < 1 || jobsPerRun > 16) {
|
||||
showServerlessValidationError(extension, "Images per run must be a whole number from 1 to 16");
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 30 || timeoutSeconds > 86400) {
|
||||
@@ -83,7 +98,6 @@ async function saveServerlessWorkerSettings(extension, worker, workerId, name) {
|
||||
...(apiKey.length > 0 && !clearApiKey ? { api_key: apiKey } : {}),
|
||||
...(clearApiKey ? { clear_api_key: true } : {}),
|
||||
jobs_per_run: jobsPerRun,
|
||||
concurrency,
|
||||
timeout_seconds: timeoutSeconds,
|
||||
// Never enable a serverless worker as a side effect of saving credentials.
|
||||
enabled: worker.enabled,
|
||||
@@ -98,7 +112,8 @@ async function saveServerlessWorkerSettings(extension, worker, workerId, name) {
|
||||
worker.provider = "runpod";
|
||||
worker.endpoint_id = endpointId;
|
||||
worker.jobs_per_run = jobsPerRun;
|
||||
worker.concurrency = concurrency;
|
||||
// Replaced by a fixed backend limit; drop any value loaded from an older config.
|
||||
delete worker.concurrency;
|
||||
worker.timeout_seconds = timeoutSeconds;
|
||||
worker.api_key_configured = clearApiKey
|
||||
? false
|
||||
@@ -178,6 +193,14 @@ export async function saveWorkerSettings(extension, workerId) {
|
||||
return;
|
||||
}
|
||||
const name = document.getElementById(`name-${workerId}`).value;
|
||||
if (worker.type === "serverless" && worker.api_key_configured) {
|
||||
// The backend deletes the stored key when a worker stops being serverless.
|
||||
const proceed = confirm(
|
||||
`Change "${worker.name}" from Serverless (Runpod) to another type?\n\n` +
|
||||
"Its saved Runpod API key will be deleted. You would need to enter it again to switch back."
|
||||
);
|
||||
if (!proceed) return;
|
||||
}
|
||||
const isRemote = workerType === "remote" || workerType === "cloud";
|
||||
const isCloud = workerType === "cloud";
|
||||
const rawHost = isRemote ? document.getElementById(`host-${workerId}`).value : window.location.hostname;
|
||||
@@ -352,8 +375,6 @@ export function cancelWorkerSettings(extension, workerId) {
|
||||
if (clearApiKeyInput) clearApiKeyInput.checked = false;
|
||||
const jobsPerRunInput = document.getElementById(`jobs-per-run-${workerId}`);
|
||||
if (jobsPerRunInput) jobsPerRunInput.value = worker.jobs_per_run ?? 1;
|
||||
const concurrencyInput = document.getElementById(`concurrency-${workerId}`);
|
||||
if (concurrencyInput) concurrencyInput.value = worker.concurrency ?? 1;
|
||||
const timeoutInput = document.getElementById(`timeout-seconds-${workerId}`);
|
||||
if (timeoutInput) timeoutInput.value = worker.timeout_seconds ?? 1800;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user