319 lines
14 KiB
JavaScript
319 lines
14 KiB
JavaScript
import { app } from "../../scripts/app.js";
|
|
import { api } from "../../scripts/api.js";
|
|
|
|
const ENVIRONMENTS_ROUTE = "/remote/environments";
|
|
const PROBE_ROUTE = "/remote/environments/probe";
|
|
const BOOTSTRAP_ROUTE = "/remote/environments/bootstrap";
|
|
const STATUS_ROUTE = "/remote/environments/status";
|
|
const STOP_ROUTE = "/remote/environments/stop";
|
|
const STYLE_ID = "comfy-remote-environment-styles";
|
|
|
|
/** Return parsed JSON or raise the server's descriptive error. */
|
|
async function requestJson(route, options = {}) {
|
|
const response = await api.fetchApi(route, options);
|
|
let payload = {};
|
|
try {
|
|
payload = await response.json();
|
|
} catch (_error) {
|
|
payload = {};
|
|
}
|
|
if (!response.ok) {
|
|
throw new Error(payload.error || `${response.status} ${response.statusText}`);
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
/** Convert a per-second price to the friendlier hourly value used by the form. */
|
|
function hourlyCost(host) {
|
|
const rawCost = host?.cost_usd_per_second ?? host?.capabilities?.reported_cost_usd_per_second;
|
|
if (rawCost == null) {
|
|
return "";
|
|
}
|
|
const perSecond = Number(rawCost);
|
|
return Number.isFinite(perSecond) ? String(perSecond * 3600) : "";
|
|
}
|
|
|
|
/** Normalize one form row into the persisted, credential-free API schema. */
|
|
function hostFromRow(row, previousHost = {}) {
|
|
const value = (name) => row.querySelector(`[name="${name}"]`)?.value?.trim() ?? "";
|
|
const checked = (name) => Boolean(row.querySelector(`[name="${name}"]`)?.checked);
|
|
const costPerHour = value("cost_per_hour");
|
|
const reserveVramGb = Number(value("reserve_vram_gb") || 0);
|
|
return {
|
|
...previousHost,
|
|
environment_id: value("environment_id").toLowerCase(),
|
|
display_name: value("display_name"),
|
|
ssh_target: value("ssh_target"),
|
|
enabled: checked("enabled"),
|
|
draining: checked("draining"),
|
|
cost_usd_per_second: costPerHour === "" ? null : Number(costPerHour) / 3600,
|
|
maximum_workers: Number(value("maximum_workers") || 1),
|
|
reserve_vram_bytes: Math.round(reserveVramGb * 1024 ** 3),
|
|
tags: value("tags").split(",").map((tag) => tag.trim()).filter(Boolean),
|
|
docker_env_file: value("docker_env_file") || null,
|
|
};
|
|
}
|
|
|
|
/** Return a compact human-readable capability summary. */
|
|
function capabilitySummary(host) {
|
|
const capabilities = host?.capabilities;
|
|
if (!capabilities) {
|
|
return host?.last_error || "Not probed yet";
|
|
}
|
|
const gpus = (capabilities.gpus ?? []).map((gpu) => {
|
|
const vramGb = Number(gpu.total_vram_bytes || 0) / 1024 ** 3;
|
|
return `${gpu.name} (${vramGb.toFixed(1)} GB)`;
|
|
});
|
|
const ramGb = Number(capabilities.total_ram_bytes || 0) / 1024 ** 3;
|
|
const workers = Array.isArray(host.workers)
|
|
? `; workers: ${host.workers.map((worker) => `${worker.worker_index ?? "?"} ${worker.state}`).join(", ") || "none"}`
|
|
: "";
|
|
const rawReportedCost = capabilities.reported_cost_usd_per_second;
|
|
const reportedCost = Number(rawReportedCost);
|
|
const cost = rawReportedCost != null && Number.isFinite(reportedCost)
|
|
? `; reported $${(reportedCost * 3600).toFixed(4)}/hour`
|
|
: "";
|
|
return `${gpus.join(", ") || "CPU only"}; ${ramGb.toFixed(1)} GB RAM; Docker ${capabilities.docker_version}${cost}${workers}`;
|
|
}
|
|
|
|
/** Install the scoped host-manager stylesheet once. */
|
|
function installStyles() {
|
|
if (document.getElementById(STYLE_ID)) {
|
|
return;
|
|
}
|
|
const style = document.createElement("style");
|
|
style.id = STYLE_ID;
|
|
style.textContent = `
|
|
.comfy-remote-overlay { position: fixed; inset: 0; z-index: 100000; display: grid; place-items: center; background: rgba(2,6,23,.72); }
|
|
.comfy-remote-dialog { width: min(1050px, 94vw); max-height: 90vh; overflow: auto; box-sizing: border-box; padding: 22px; border: 1px solid var(--border-color, #475569); border-radius: 12px; background: var(--comfy-menu-bg, #18181b); color: var(--input-text, #f8fafc); box-shadow: 0 24px 80px rgba(0,0,0,.55); font: 13px/1.4 ui-sans-serif, system-ui, sans-serif; }
|
|
.comfy-remote-dialog h2 { margin: 0 0 6px; font-size: 20px; }
|
|
.comfy-remote-dialog .intro { margin: 0 0 18px; color: #cbd5e1; }
|
|
.comfy-remote-host { display: grid; grid-template-columns: 1fr 1.2fr 1.4fr .65fr .65fr .65fr 1fr 1.2fr; gap: 8px; margin: 0 0 10px; padding: 12px; border: 1px solid #475569; border-radius: 8px; }
|
|
.comfy-remote-host label { display: flex; min-width: 0; flex-direction: column; gap: 4px; color: #cbd5e1; font-size: 11px; }
|
|
.comfy-remote-host input { min-width: 0; box-sizing: border-box; padding: 7px; border: 1px solid #64748b; border-radius: 5px; background: #0f172a; color: #f8fafc; }
|
|
.comfy-remote-host .checks { display: flex; align-items: center; gap: 12px; grid-column: 1 / span 3; }
|
|
.comfy-remote-host .checks label { flex-direction: row; align-items: center; }
|
|
.comfy-remote-host .capabilities { grid-column: 4 / -1; align-self: center; color: #94a3b8; }
|
|
.comfy-remote-host .actions { display: flex; gap: 8px; grid-column: 1 / -1; }
|
|
.comfy-remote-dialog button { padding: 7px 11px; border: 1px solid #64748b; border-radius: 6px; background: #334155; color: #f8fafc; cursor: pointer; }
|
|
.comfy-remote-dialog button.primary { border-color: #2563eb; background: #2563eb; }
|
|
.comfy-remote-dialog button.danger { border-color: #991b1b; background: #7f1d1d; }
|
|
.comfy-remote-dialog .footer { display: flex; justify-content: space-between; gap: 8px; margin-top: 16px; }
|
|
.comfy-remote-dialog .footer-group { display: flex; gap: 8px; }
|
|
.comfy-remote-dialog .status { min-height: 20px; margin-top: 10px; color: #93c5fd; }
|
|
@media (max-width: 900px) { .comfy-remote-host { grid-template-columns: 1fr 1fr; } .comfy-remote-host .checks, .comfy-remote-host .capabilities, .comfy-remote-host .actions { grid-column: 1 / -1; } }
|
|
`;
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
/** Construct one editable SSH host card. */
|
|
function createHostRow(host, manager) {
|
|
const row = document.createElement("section");
|
|
row.className = "comfy-remote-host";
|
|
row.__remoteHost = host;
|
|
const field = (label, name, value, type = "text", step = null) => {
|
|
const wrapper = document.createElement("label");
|
|
wrapper.textContent = label;
|
|
const input = document.createElement("input");
|
|
input.name = name;
|
|
input.type = type;
|
|
input.value = value ?? "";
|
|
if (step != null) input.step = step;
|
|
wrapper.appendChild(input);
|
|
row.appendChild(wrapper);
|
|
};
|
|
field("Environment ID", "environment_id", host.environment_id);
|
|
field("Display name", "display_name", host.display_name);
|
|
field("SSH target or alias", "ssh_target", host.ssh_target);
|
|
field("Cost USD/hour", "cost_per_hour", hourlyCost(host), "number", "0.0001");
|
|
field("Max workers", "maximum_workers", host.maximum_workers ?? 1, "number", "1");
|
|
field("Reserve VRAM GB", "reserve_vram_gb", Number(host.reserve_vram_bytes || 0) / 1024 ** 3, "number", "0.1");
|
|
field("Tags (comma separated)", "tags", (host.tags ?? []).join(", "));
|
|
field("Remote Docker env file", "docker_env_file", host.docker_env_file ?? "");
|
|
|
|
const checks = document.createElement("div");
|
|
checks.className = "checks";
|
|
for (const [label, name, checked] of [
|
|
["Enabled", "enabled", host.enabled ?? true],
|
|
["Drain (no new work)", "draining", host.draining ?? false],
|
|
]) {
|
|
const wrapper = document.createElement("label");
|
|
const input = document.createElement("input");
|
|
input.type = "checkbox";
|
|
input.name = name;
|
|
input.checked = checked;
|
|
wrapper.append(input, label);
|
|
checks.appendChild(wrapper);
|
|
}
|
|
row.appendChild(checks);
|
|
|
|
const capabilities = document.createElement("div");
|
|
capabilities.className = "capabilities";
|
|
capabilities.textContent = `${host.health ?? "unknown"}: ${capabilitySummary(host)}`;
|
|
row.appendChild(capabilities);
|
|
|
|
const actions = document.createElement("div");
|
|
actions.className = "actions";
|
|
const actionButton = (label, callback, className = "") => {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.textContent = label;
|
|
button.className = className;
|
|
button.addEventListener("click", callback);
|
|
actions.appendChild(button);
|
|
};
|
|
actionButton("Save and probe", () => manager.saveAndOperate(row, PROBE_ROUTE));
|
|
actionButton("Build / update worker", () => manager.saveAndOperate(row, BOOTSTRAP_ROUTE));
|
|
actionButton("Refresh workers", () => manager.saveAndOperate(row, STATUS_ROUTE));
|
|
actionButton("Stop workers", () => {
|
|
if (typeof window.confirm !== "function" || window.confirm(`Stop managed workers on ${host.display_name || "this host"}?`)) {
|
|
void manager.saveAndOperate(row, STOP_ROUTE);
|
|
}
|
|
});
|
|
actionButton("Remove", () => row.remove(), "danger");
|
|
row.appendChild(actions);
|
|
return row;
|
|
}
|
|
|
|
/** Manage the complete SSH host registry in a ComfyUI-local dialog. */
|
|
class RemoteEnvironmentManager {
|
|
constructor(config) {
|
|
this.config = config;
|
|
this.overlay = document.createElement("div");
|
|
this.overlay.className = "comfy-remote-overlay";
|
|
this.dialog = document.createElement("div");
|
|
this.dialog.className = "comfy-remote-dialog";
|
|
this.rows = document.createElement("div");
|
|
this.status = document.createElement("div");
|
|
this.status.className = "status";
|
|
this.overlay.appendChild(this.dialog);
|
|
}
|
|
|
|
render() {
|
|
const title = document.createElement("h2");
|
|
title.textContent = "Remote execution environments";
|
|
const intro = document.createElement("p");
|
|
intro.className = "intro";
|
|
intro.textContent = "Add SSH destinations that already work non-interactively and have Docker installed. Host-key verification is strict; credentials stay in your SSH agent/config and are never stored here.";
|
|
this.dialog.append(title, intro, this.rows);
|
|
for (const host of this.config.hosts ?? []) {
|
|
this.rows.appendChild(createHostRow(host, this));
|
|
}
|
|
const footer = document.createElement("div");
|
|
footer.className = "footer";
|
|
const left = document.createElement("div");
|
|
left.className = "footer-group";
|
|
const right = document.createElement("div");
|
|
right.className = "footer-group";
|
|
const button = (label, callback, className = "") => {
|
|
const element = document.createElement("button");
|
|
element.type = "button";
|
|
element.textContent = label;
|
|
element.className = className;
|
|
element.addEventListener("click", callback);
|
|
return element;
|
|
};
|
|
left.appendChild(button("Add host", () => {
|
|
this.rows.appendChild(createHostRow({ enabled: true, maximum_workers: 1 }, this));
|
|
}));
|
|
right.append(
|
|
button("Close", () => this.overlay.remove()),
|
|
button("Save", () => this.save(), "primary"),
|
|
);
|
|
footer.append(left, right);
|
|
this.dialog.append(this.status, footer);
|
|
this.overlay.addEventListener("click", (event) => {
|
|
if (event.target === this.overlay) this.overlay.remove();
|
|
});
|
|
document.body.appendChild(this.overlay);
|
|
}
|
|
|
|
hosts() {
|
|
return [...this.rows.querySelectorAll(".comfy-remote-host")].map((row) =>
|
|
hostFromRow(row, row.__remoteHost),
|
|
);
|
|
}
|
|
|
|
async save() {
|
|
this.status.textContent = "Saving…";
|
|
try {
|
|
this.config = await requestJson(ENVIRONMENTS_ROUTE, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ version: 1, hosts: this.hosts() }),
|
|
});
|
|
this.status.textContent = "Saved remote execution environments.";
|
|
return this.config;
|
|
} catch (error) {
|
|
this.status.textContent = `Save failed: ${String(error?.message ?? error)}`;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async saveAndOperate(row, route) {
|
|
try {
|
|
await this.save();
|
|
const environmentId = row.querySelector('[name="environment_id"]')?.value?.trim();
|
|
this.status.textContent = route === PROBE_ROUTE
|
|
? "Probing host…"
|
|
: route === BOOTSTRAP_ROUTE
|
|
? "Building worker image…"
|
|
: route === STOP_ROUTE
|
|
? "Stopping managed workers…"
|
|
: "Refreshing worker status…";
|
|
const operationResult = await requestJson(route, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ environment_id: environmentId }),
|
|
});
|
|
if (route === STATUS_ROUTE) {
|
|
row.__remoteHost = {
|
|
...row.__remoteHost,
|
|
workers: operationResult.workers ?? [],
|
|
};
|
|
row.querySelector(".capabilities").textContent =
|
|
`${row.__remoteHost.health ?? "unknown"}: ${capabilitySummary(row.__remoteHost)}`;
|
|
this.status.textContent = "Refreshed worker status.";
|
|
return;
|
|
}
|
|
if (route === STOP_ROUTE) {
|
|
row.__remoteHost = { ...row.__remoteHost, workers: [] };
|
|
row.querySelector(".capabilities").textContent =
|
|
`${row.__remoteHost.health ?? "unknown"}: ${capabilitySummary(row.__remoteHost)}`;
|
|
this.status.textContent = `Stopped ${operationResult.removed_containers?.length ?? 0} managed worker(s).`;
|
|
return;
|
|
}
|
|
this.overlay.remove();
|
|
await openRemoteEnvironmentManager();
|
|
} catch (error) {
|
|
this.status.textContent = `Operation failed: ${String(error?.message ?? error)}`;
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Open the native extension manager for SSH Docker execution environments. */
|
|
export async function openRemoteEnvironmentManager() {
|
|
installStyles();
|
|
const config = await requestJson(ENVIRONMENTS_ROUTE);
|
|
new RemoteEnvironmentManager(config).render();
|
|
}
|
|
|
|
app.registerExtension({
|
|
name: "Comfy.RemoteExecution.Environments",
|
|
async setup() {
|
|
app.ui.settings.addSetting({
|
|
id: "Comfy.RemoteExecution.ManageEnvironments",
|
|
name: "Remote Execution: SSH environments",
|
|
type: () => {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.textContent = "Manage SSH hosts";
|
|
button.addEventListener("click", () => void openRemoteEnvironmentManager());
|
|
return button;
|
|
},
|
|
defaultValue: null,
|
|
tooltip: "Configure, probe, and bootstrap Docker workers reached through SSH.",
|
|
});
|
|
},
|
|
});
|