fix: simplify serverless cards and keep status dots circular
This commit is contained in:
+3
-3
@@ -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;",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 `<span class="dist-worker-info__title">${escapeWorkerInfo(data.name)}</span><br><span class="dist-worker-info__meta">Runpod Serverless • ${escapeWorkerInfo(endpoint)} • ${escapeWorkerInfo(jobsPerRun)} jobs/run • ${escapeWorkerInfo(concurrency)} slots</span>`;
|
||||
return `<span class="dist-worker-info__title">${escapeWorkerInfo(data.name)}</span><br><span class="dist-worker-info__meta">Runpod Serverless</span>`;
|
||||
}
|
||||
|
||||
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),
|
||||
|
||||
+4
-17
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+30
-17
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user