From 6eb94a52fefa1b0715d99d9fb0d9998b648301c4 Mon Sep 17 00:00:00 2001 From: Robert Wojciechowski Date: Sun, 11 Oct 2026 06:01:46 +0000 Subject: [PATCH] fix: simplify serverless cards and keep status dots circular --- web/constants.js | 6 +-- web/distributed.css | 11 ++++++ web/tests/serverlessWorkers.test.js | 57 ++++++++++++++++++++++++++--- web/ui.js | 35 +++++++----------- web/ui/entityCard.js | 21 ++--------- web/workerLifecycle.js | 47 +++++++++++++++--------- 6 files changed, 113 insertions(+), 64 deletions(-) diff --git a/web/constants.js b/web/constants.js index 3a8ea89..6a5a09c 100644 --- a/web/constants.js +++ b/web/constants.js @@ -131,7 +131,7 @@ export const PULSE_ANIMATION_CSS = ` `; export const UI_STYLES = { - statusDot: "display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 10px;", + statusDot: "display: inline-block; flex: 0 0 10px; 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: var(--dist-label-text, #ccc); font-weight: 500;", @@ -147,12 +147,12 @@ export const UI_STYLES = { // Column 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;", + contentColumn: "flex: 1; min-width: 0; display: flex; flex-direction: column; transition: background-color 0.2s ease;", 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;", - workerContent: "display: flex; align-items: center; gap: 10px; flex: 1;", + workerContent: "display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0;", // Form and controls styles buttonGroup: "display: flex; gap: 4px; margin-top: 10px;", diff --git a/web/distributed.css b/web/distributed.css index b6b3352..8b48e31 100644 --- a/web/distributed.css +++ b/web/distributed.css @@ -235,6 +235,17 @@ border-radius: 4px; } +.dist-worker-info { + min-width: 0; + overflow-wrap: anywhere; +} + +.dist-serverless-status { + display: block; + margin-top: 4px; + line-height: 1.4; +} + .dist-worker-info__title { color: var(--dist-card-title) !important; font-size: 1.03em; diff --git a/web/tests/serverlessWorkers.test.js b/web/tests/serverlessWorkers.test.js index 5c6d25e..e502777 100644 --- a/web/tests/serverlessWorkers.test.js +++ b/web/tests/serverlessWorkers.test.js @@ -5,7 +5,7 @@ vi.mock("../../scripts/api.js", () => ({ api: { clientId: "test-client" } }), { import { createApiClient } from "../apiClient.js"; import { createWorkerSettingsForm } from "../ui/settingsForm.js"; import { DistributedUI } from "../ui.js"; -import { checkWorkerStatus, getWorkerUrl, launchWorker, stopWorker, viewWorkerLog } from "../workerLifecycle.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 { performPreflightCheck } from "../executionUtils.js"; @@ -16,7 +16,9 @@ function createDocumentMock() { const element = { tagName, children: [], + dataset: {}, style: {}, + setAttribute(name, value) { this[name] = value; }, classList: { values: new Set(), add(...names) { names.forEach((name) => this.values.add(name)); }, @@ -259,6 +261,50 @@ describe("Runpod serverless worker panel", () => { expect(worker).not.toHaveProperty("api_key"); }); + it.each([ + [undefined, "Checking status…"], + [{ ok: true, queueRemaining: 0, jobs: [] }, "Ready"], + [{ ok: true, queueRemaining: 1, jobs: [{ status: "IN_QUEUE" }] }, "Queued"], + [{ ok: true, jobs: [{ status: "IN_PROGRESS" }] }, "Running"], + [{ ok: true, jobs: [{ status: "RUNNING" }, { status: "IN_QUEUE" }] }, "Running"], + [{ ok: true, jobs: [{ status: "RUNNING" }, { status: "IN_PROGRESS" }] }, "Running · 2 jobs"], + [{ ok: true, jobs: [{ status: "DOWNLOADING" }] }, "Downloading result"], + [{ ok: true, jobs: [{ status: "CANCEL_REQUESTED" }] }, "Cancelling"], + [{ ok: true, queueRemaining: 1 }, "Active · 1 job"], + [{ ok: true, queueRemaining: 3 }, "Active · 3 jobs"], + [{ ok: true, jobs: [{ status: "COMPLETED" }] }, "Ready"], + [{ ok: true, jobs: [{ status: "FAILED" }] }, "Last job failed"], + [{ ok: false, message: "Check the saved endpoint ID and API key" }, "Unavailable"], + ])("formats provider state as one plain-language summary: %j", (status, expected) => { + expect(formatServerlessStatus(status)).toBe(expected); + }); + + it("keeps the serverless card compact while retaining its expanded settings", () => { + const ui = new DistributedUI(); + const worker = { + id: "compact", name: "Used Car Inventory", type: "serverless", enabled: true, + endpoint_id: "ep_private_id", jobs_per_run: 1, concurrency: 1, + }; + const extension = { + config: { workers: [worker] }, + state: { + isWorkerExpanded: () => false, + getWorker: () => ({ serverlessStatus: { ok: true, queueRemaining: 1, jobs: [{ status: "IN_QUEUE" }] } }), + }, + }; + const card = ui.renderEntityCard("worker", worker, extension); + const collect = (element) => [element, ...(element.children || []).flatMap(collect)]; + const info = collect(card).find((element) => element.classList.values.has("dist-worker-info")); + expect(info.innerHTML).toContain("Runpod Serverless"); + expect(info.innerHTML).not.toMatch(/ep_private_id|jobs\/run|slots/); + 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("controls-compact")).toBe(false); + expect(elements.get("status-compact").style.cssText).toContain("flex: 0 0 10px"); + }); + it("uses provider status instead of a direct ComfyUI URL probe", async () => { const statusElement = { textContent: "" }; elements.set("serverless-status-w4", statusElement); @@ -288,8 +334,8 @@ describe("Runpod serverless worker panel", () => { expect(extension.state.setWorkerStatus).toHaveBeenCalledWith("w4", { online: true, processing: true, queueCount: 0, }); - expect(statusElement.textContent).toContain("Ready to scale"); - expect(statusElement.textContent).toContain("RUNNING"); + expect(statusElement.textContent).toBe("Running"); + expect(statusElement.title).toBe("Ready to scale"); }); it("considers a healthy zero-worker Runpod pool ready and never builds a worker URL", async () => { @@ -312,8 +358,7 @@ describe("Runpod serverless worker panel", () => { const ui = new DistributedUI(); const worker = { id: "w-controls", name: "Runpod", type: "serverless", enabled: true }; const controls = ui.createControlsSection({ dynamic: true }, worker, {}, true); - const collect = (element) => [element, ...(element.children || []).flatMap(collect)]; - const elementsInControls = collect(controls); + const extension = { config: { workers: [worker] }, api: { @@ -321,7 +366,7 @@ describe("Runpod serverless worker panel", () => { }, }; - expect(elementsInControls.filter((element) => element.tagName === "button")).toHaveLength(0); + expect(controls).toBeNull(); await launchWorker(extension, worker.id); await stopWorker(extension, worker.id); await viewWorkerLog(extension, worker.id, true); diff --git a/web/ui.js b/web/ui.js index adc4b6a..6f81fbb 100644 --- a/web/ui.js +++ b/web/ui.js @@ -69,10 +69,7 @@ const cardConfigs = { const isServerless = data.type === 'serverless'; if (isServerless) { - const endpoint = data.endpoint_id || "Endpoint not configured"; - const jobsPerRun = data.jobs_per_run ?? 1; - const concurrency = data.concurrency ?? 1; - return `${escapeWorkerInfo(data.name)}
Runpod Serverless • ${escapeWorkerInfo(endpoint)} • ${escapeWorkerInfo(jobsPerRun)} jobs/run • ${escapeWorkerInfo(concurrency)} slots`; + return `${escapeWorkerInfo(data.name)}
Runpod Serverless`; } if (isCloud) { @@ -576,6 +573,8 @@ export class DistributedUI { createControlsSection(config, data, extension, isRemote) { if (!config) return null; + // Serverless has no direct-worker actions; its provider is already in the header. + if (config.dynamic && data?.type === "serverless") return null; const controlsDiv = document.createElement("div"); controlsDiv.id = `controls-${data?.id || 'master'}`; @@ -607,24 +606,18 @@ export class DistributedUI { controlsWrapper.appendChild(badge); } else if (config.dynamic && data) { if (isRemote) { - if (data.type === "serverless") { - const workerTypeBadge = this.createInfoBox("Runpod Serverless worker"); - workerTypeBadge.title = "Provider-managed workers scale on demand"; - controlsWrapper.appendChild(workerTypeBadge); - } else { - 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 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); - } + 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), diff --git a/web/ui/entityCard.js b/web/ui/entityCard.js index 4ac988c..bbf0d69 100644 --- a/web/ui/entityCard.js +++ b/web/ui/entityCard.js @@ -1,20 +1,6 @@ -import { updateWorkerControls, toggleWorkerExpanded } from "../workerLifecycle.js"; +import { updateWorkerControls, toggleWorkerExpanded, formatServerlessStatus } from "../workerLifecycle.js"; import { isRemoteWorker } from "../workerSettings.js"; -function formatServerlessStatus(status) { - if (!status) return "Runpod status pending"; - const message = status.message || (status.ok ? "Ready to scale" : "Runpod status unavailable"); - const queue = Number(status.queueRemaining); - const parts = [message]; - if (Number.isFinite(queue)) parts.push(`${Math.max(0, queue)} queued`); - const jobs = (Array.isArray(status.jobs) ? status.jobs : []) - .slice(0, 2) - .map((job) => String(job?.status || "").trim()) - .filter(Boolean); - if (jobs.length) parts.push(`Jobs: ${jobs.join(", ")}`); - return parts.join(" • "); -} - export function renderEntityCard(ui, cardConfigs, entityType, data, extension) { const config = cardConfigs[entityType] || {}; const isPlaceholder = entityType === 'blueprint' || entityType === 'add'; @@ -92,9 +78,10 @@ export function renderEntityCard(ui, cardConfigs, entityType, data, extension) { const statusMessage = document.createElement("small"); statusMessage.id = `serverless-status-${data.id}`; statusMessage.className = "dist-worker-info__meta dist-serverless-status"; - statusMessage.style.display = "block"; statusMessage.setAttribute("aria-live", "polite"); - statusMessage.textContent = formatServerlessStatus(extension.state.getWorker(data.id)?.serverlessStatus); + const status = extension.state.getWorker(data.id)?.serverlessStatus; + statusMessage.textContent = data.enabled ? formatServerlessStatus(status) : "Disabled"; + statusMessage.title = status?.message || ""; infoSpan.appendChild(statusMessage); } diff --git a/web/workerLifecycle.js b/web/workerLifecycle.js index ffeaeda..658bec2 100644 --- a/web/workerLifecycle.js +++ b/web/workerLifecycle.js @@ -41,26 +41,39 @@ function setButtonVisibility(button, visible) { button.style.display = visible ? "" : "none"; } +export function formatServerlessStatus(status) { + if (!status) return "Checking status…"; + if (!status.ok) return "Unavailable"; + const jobs = (Array.isArray(status.jobs) ? status.jobs : []) + .map((job) => String(job?.status || "").trim().toUpperCase()); + // Prefer current work over old ledger entries, and avoid raw provider enums. + const phases = [ + ["Downloading result", ["DOWNLOADING"]], + ["Cancelling", ["CANCEL_REQUESTED"]], + ["Running", ["RUNNING", "PROCESSING", "IN_PROGRESS"]], + ["Starting", ["STARTING"]], + ["Submitting", ["SUBMITTING"]], + ["Queued", ["WAITING", "IN_QUEUE", "QUEUED", "PENDING"]], + ]; + for (const [label, states] of phases) { + const count = jobs.filter((state) => states.includes(state)).length; + if (count) return count > 1 ? `${label} · ${count} jobs` : label; + } + // Health counts queued AND running jobs, not just the waiting queue. + const remaining = Number(status.queueRemaining); + if (Number.isFinite(remaining) && remaining > 0) { + return `Active · ${remaining} ${remaining === 1 ? "job" : "jobs"}`; + } + if (jobs[0] === "FAILED") return "Last job failed"; + if (jobs[0] === "TIMED_OUT") return "Last job timed out"; + return "Ready"; +} + function updateServerlessStatusMessage(workerId, status) { const messageElement = document.getElementById(`serverless-status-${workerId}`); if (!messageElement) return; - - const message = typeof status?.message === "string" && status.message - ? status.message - : status?.ok ? "Ready to scale" : "Runpod status unavailable"; - const queueRemaining = Number(status?.queueRemaining); - const parts = [message]; - if (Number.isFinite(queueRemaining)) { - parts.push(`${Math.max(0, queueRemaining)} queued`); - } - const jobStatuses = (Array.isArray(status?.jobs) ? status.jobs : []) - .slice(0, 2) - .map((job) => String(job?.status || "").trim()) - .filter(Boolean); - if (jobStatuses.length) { - parts.push(`Jobs: ${jobStatuses.join(", ")}`); - } - messageElement.textContent = parts.join(" • "); + messageElement.textContent = formatServerlessStatus(status); + messageElement.title = status?.message || ""; } export async function checkAllWorkerStatuses(extension) {