import { app } from "../../scripts/app.js";
// Bar chart with nodes icon
const STATS_ICON = ``;
// Single source of truth for per-status presentation: badge label, accent
// color, row background + hover, and summary-card colors. Used by the nodes
// tab, models tab, and summary bars so they all stay in sync.
const STATUS_META = {
safe_to_remove: { label: "safe to remove", color: "#e44", bg: "#2a1515", hover: "#3a2020", summaryBg: "#3a1a1a", summaryText: "#c99" },
consider_removing: { label: "consider removing", color: "#e90", bg: "#2a2215", hover: "#3a2e20", summaryBg: "#2a2215", summaryText: "#ca8" },
unused_new: { label: "unused <1mo", color: "#68f", bg: "#1a1a25", hover: "#252530", summaryBg: "#1a1a2a", summaryText: "#99b" },
used: { label: "used", color: "#4a4", bg: "#151a15", hover: "#202a20", summaryBg: "#1a2a1a", summaryText: "#9c9" },
uninstalled: { label: "uninstalled", color: "#555", bg: "#1a1a1a", hover: "#252525", summaryBg: "#1a1a1a", summaryText: "#888" },
};
// Tiers that may offer a "Disable" action (when ComfyUI Manager is available).
const DISABLEABLE_TIERS = new Set(["safe_to_remove", "consider_removing"]);
app.registerExtension({
name: "comfyui.nodes_stats",
async setup() {
const btn = document.createElement("button");
btn.innerHTML = STATS_ICON;
btn.title = "Node Stats";
btn.className = "comfyui-button comfyui-menu-mobile-collapse";
btn.onclick = () => showStatsDialog();
btn.style.cssText =
"display:flex;align-items:center;justify-content:center;padding:6px;cursor:pointer;";
if (app.menu?.settingsGroup?.element) {
app.menu.settingsGroup.element.before(btn);
} else {
const menu = document.querySelector(".comfy-menu");
if (menu) {
menu.append(btn);
}
}
const searchBtn = document.createElement("button");
searchBtn.textContent = "⌕";
searchBtn.title = "Search disabled-pack nodes (Ctrl/Cmd+Shift+D)";
searchBtn.className = "comfyui-button comfyui-menu-mobile-collapse";
searchBtn.style.cssText = "display:flex;align-items:center;justify-content:center;padding:6px;cursor:pointer;font-size:16px;";
searchBtn.onclick = () => openMirrorSearch();
if (app.menu?.settingsGroup?.element) app.menu.settingsGroup.element.before(searchBtn);
else document.querySelector(".comfy-menu")?.append(searchBtn);
// Detect missing/disabled nodes whenever a workflow is loaded.
const origLoad = app.loadGraphData?.bind(app);
if (origLoad) {
app.loadGraphData = function (...args) {
const r = origLoad(...args);
setTimeout(() => onWorkflowLoaded(), 0); // after graph settles
return r;
};
}
// Once the app has settled, auto-disable trial packages that went unused for
// their full budget of distinct boot-days. Inert when ComfyUI Manager is absent.
setTimeout(() => { processExpiredTrials().catch(() => {}); }, 3000);
window.addEventListener("keydown", (e) => {
if (!(e.shiftKey && (e.ctrlKey || e.metaKey) && (e.key === "D" || e.key === "d"))) return;
const t = e.target;
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
e.preventDefault();
openMirrorSearch();
});
},
});
// Return the set of node types present in the current graph that LiteGraph
// doesn't have registered — i.e. nodes from missing or disabled packages.
function unresolvedNodeTypes() {
const types = new Set();
const nodes = app.graph?._nodes || [];
for (const n of nodes) {
const t = n.type;
if (t && !LiteGraph.registered_node_types[t]) types.add(t);
}
return [...types];
}
// Latest workflow scan, shared so showStatsDialog can render the Workflow tab.
let _lastWorkflowScan = { disabled: [], missing: [] };
async function onWorkflowLoaded() {
const types = unresolvedNodeTypes();
_lastWorkflowScan = await classifyUnresolved(types);
if (_lastWorkflowScan.disabled.length || _lastWorkflowScan.missing.length) {
showStatsDialog("workflow"); // auto-open on the Workflow tab
}
}
async function showStatsDialog(initialTab = "nodes") {
let data, modelData, managerInfo, trials = [];
try {
const [pkgResp, modelResp, mgr, trialsResp] = await Promise.all([
fetch("/nodes-stats/packages"),
fetch("/nodes-stats/models"),
fetchManagerInfo(),
fetch("/nodes-stats/trials").catch(() => null),
]);
if (!pkgResp.ok) { alert("Failed to load node stats: HTTP " + pkgResp.status); return; }
if (!modelResp.ok) { alert("Failed to load model stats: HTTP " + modelResp.status); return; }
data = await pkgResp.json();
modelData = await modelResp.json();
managerInfo = mgr;
if (trialsResp && trialsResp.ok) { try { trials = await trialsResp.json(); } catch { trials = []; } }
if (!Array.isArray(data) || !Array.isArray(modelData)) {
alert("Failed to load stats: unexpected response format");
return;
}
} catch (e) {
alert("Failed to load stats: " + e.message);
return;
}
const custom = data.filter((p) => p.package !== "__builtin__");
// Remove existing dialog if any
const existing = document.getElementById("nodes-stats-dialog");
if (existing) existing.remove();
const overlay = document.createElement("div");
overlay.id = "nodes-stats-dialog";
overlay.style.cssText =
"position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.6);z-index:10000;display:flex;align-items:center;justify-content:center;";
overlay.addEventListener("click", (e) => {
if (e.target === overlay) overlay.remove();
});
const dialog = document.createElement("div");
dialog.style.cssText =
"background:#1e1e1e;color:#ddd;border-radius:8px;padding:24px;max-width:800px;width:90%;max-height:85vh;overflow-y:auto;font-family:monospace;font-size:13px;";
let html = dialogStyle();
html += `
Usage Stats
`;
// Tab switcher — wired via addEventListener after insertion, no onclick globals
html += `
`;
// Nodes tab content
html += `
`;
html += buildNodesTabContent(custom, managerInfo);
html += `
`;
// Models tab content
html += `
`;
html += buildModelsTabContent(modelData);
html += `
`;
// Workflow tab content (missing / disabled nodes in the loaded workflow)
html += `
`;
html += buildWorkflowTabContent(_lastWorkflowScan, trials);
html += `
`;
dialog.innerHTML = html;
overlay.appendChild(dialog);
document.body.appendChild(overlay);
// Tab switch — local function, no window pollution
const TABS = ["nodes", "models", "workflow"];
function switchTab(tab) {
for (const t of TABS) {
dialog.querySelector(`#ns-content-${t}`).style.display = t === tab ? "" : "none";
const b = dialog.querySelector(`#ns-tab-${t}`);
b.style.borderBottomColor = t === tab ? "#4a4" : "transparent";
b.style.color = t === tab ? "#4a4" : "#888";
b.style.fontWeight = t === tab ? "bold" : "normal";
}
}
for (const t of TABS) {
dialog.querySelector(`#ns-tab-${t}`).addEventListener("click", () => switchTab(t));
}
dialog.querySelector("#nodes-stats-close").addEventListener("click", () => overlay.remove());
// Toggle expandable rows
dialog.querySelectorAll(".pkg-row").forEach((row) => {
row.addEventListener("click", () => {
const detail = row.nextElementSibling;
if (detail && detail.classList.contains("pkg-detail")) {
detail.style.display =
detail.style.display === "none" ? "table-row" : "none";
const arrow = row.querySelector(".arrow");
if (arrow)
arrow.textContent = detail.style.display === "none" ? "▶" : "▼";
}
});
});
wireDisableButtons(dialog, managerInfo);
wireWorkflowButtons(dialog);
switchTab(TABS.includes(initialTab) ? initialTab : "nodes");
// Easter egg: click "used" badge 5 times to show podium
let eggClicks = 0;
let eggTimer = null;
const usedBadge = dialog.querySelector("#nodes-stats-used-badge");
if (usedBadge) {
usedBadge.addEventListener("click", () => {
eggClicks++;
clearTimeout(eggTimer);
eggTimer = setTimeout(() => (eggClicks = 0), 1500);
if (eggClicks >= 5) {
eggClicks = 0;
const allNodes = custom
.flatMap((p) => p.nodes.map((n) => ({ ...n, pkg: p.package })))
.sort((a, b) => b.count - a.count);
showPodium(allNodes.slice(0, 3), overlay);
}
});
}
}
// Scoped CSS for the dialog: row backgrounds + hover (replaces inline
// onmouseover/onmouseout) and the action buttons. Generated from STATUS_META.
function dialogStyle() {
let rows = "";
for (const [status, m] of Object.entries(STATUS_META)) {
rows += `#nodes-stats-dialog .ns-row-${status}{background:${m.bg};}`;
rows += `#nodes-stats-dialog .ns-row-${status}:hover{background:${m.hover};}`;
}
return ``;
}
// Summary cards row. items: [{count, status, label, id?}]
function summaryBar(items) {
let html = `
`;
for (const it of items) {
const m = STATUS_META[it.status];
const idAttr = it.id ? ` id="${it.id}"` : "";
const cursor = it.id ? "cursor:default;user-select:none;" : "";
html += `
${it.count}${it.label}
`;
}
html += `
`;
return html;
}
function buildNodesTabContent(custom, managerInfo) {
const byStatus = (s) => custom.filter((p) => p.status === s);
const safeToRemove = byStatus("safe_to_remove");
const considerRemoving = byStatus("consider_removing");
const unusedNew = byStatus("unused_new");
const used = byStatus("used");
const uninstalled = byStatus("uninstalled");
let html = summaryBar([
{ count: safeToRemove.length, status: "safe_to_remove", label: "safe to remove" },
{ count: considerRemoving.length, status: "consider_removing", label: "consider removing" },
{ count: unusedNew.length, status: "unused_new", label: "unused <1 month" },
{ count: used.length, status: "used", label: "used", id: "nodes-stats-used-badge" },
]);
html += renderSection("Safe to Remove", "Unused for 2+ months", "safe_to_remove", safeToRemove, managerInfo);
html += renderSection("Consider Removing", "Unused for 1-2 months", "consider_removing", considerRemoving, managerInfo);
html += renderSection("Recently Unused", "Unused for less than 1 month", "unused_new", unusedNew, managerInfo);
html += renderSection("Used", "", "used", used, managerInfo);
html += renderSection("Uninstalled", "Previously tracked, no longer installed", "uninstalled", uninstalled, managerInfo);
return html;
}
function renderSection(title, subtitle, status, packages, managerInfo) {
if (packages.length === 0) return "";
const color = STATUS_META[status].color;
const withActions = !!managerInfo && DISABLEABLE_TIERS.has(status);
const eligible = withActions
? packages.filter((p) => isDisableEligible(p, managerInfo)).map((p) => p.package)
: [];
let action = "";
if (eligible.length > 0) {
action = ``;
}
let html = `
${escapeHtml(title)}`;
if (subtitle) html += ` — ${escapeHtml(subtitle)}`;
html += `
${action}
`;
html += buildTable(packages, status, withActions, managerInfo);
return html;
}
// A package can be disabled only if ComfyUI Manager knows it (by directory
// name) and it is currently active (any state other than already-disabled).
function isDisableEligible(pkg, managerInfo) {
if (!managerInfo || !pkg.installed) return false;
const info = managerInfo[pkg.package];
return !!(info && info.state && info.state !== "disabled");
}
function buildModelsTabContent(modelData) {
const allModels = modelData.flatMap((g) => g.models);
const count = (s) => allModels.filter((m) => m.status === s).length;
let html = summaryBar([
{ count: count("safe_to_remove"), status: "safe_to_remove", label: "safe to remove" },
{ count: count("consider_removing"), status: "consider_removing", label: "consider removing" },
{ count: count("unused_new"), status: "unused_new", label: "unused <1 month" },
{ count: count("used"), status: "used", label: "used" },
]);
if (allModels.length === 0) {
html += `
No models tracked yet. Run a workflow to start.
`;
return html;
}
for (const group of modelData) {
if (group.models.length === 0) continue;
const title = group.model_type.charAt(0).toUpperCase() + group.model_type.slice(1).replace(/_/g, " ");
html += sectionHeader(title, `${group.models.length} model${group.models.length !== 1 ? "s" : ""}`, "#4a4");
html += buildModelTable(group.models);
}
return html;
}
function buildModelTable(models) {
let html = `
Model
Executions
Last Used
Status
`;
for (const m of models) {
const meta = STATUS_META[m.status] || STATUS_META.used;
const lastSeen = m.last_seen ? new Date(m.last_seen).toLocaleDateString() : "—";
html += `
${escapeHtml(m.model_name)}
${m.count}
${lastSeen}
${meta.label}
`;
}
html += `
`;
return html;
}
// Render the Workflow tab from a classification result. `disabled` entries get
// re-enable actions (temporary trial or permanent); `missing` entries get an
// Install button that defers to ComfyUI Manager.
function buildWorkflowTabContent({ disabled, missing }, trials) {
const trialByPkg = Object.fromEntries((trials || []).map((t) => [t.package, t]));
let html = "";
if (!disabled.length && !missing.length) {
return `
No missing or disabled nodes in the current workflow.
`;
}
if (disabled.length) {
html += sectionHeader("Disabled", "Installed but disabled — re-enable to use", "#e90");
html += `
`;
for (const d of disabled) {
const t = trialByPkg[d.pkg];
const note = t ? `on trial · ${t.days_remaining}d left` : "";
html += `
${escapeHtml(d.type)}
${escapeHtml(d.pkg)} ${note}
`;
}
html += `
`;
}
if (missing.length) {
html += sectionHeader("Missing", "Not installed — install via ComfyUI Manager", "#e44");
html += `
`;
for (const m of missing) {
html += `
${escapeHtml(m.type)}
${m.pkg ? escapeHtml(m.pkg) : "unknown"}
${m.pkg ? `` : "—"}
`;
}
html += `
`;
}
return html;
}
function sectionHeader(title, subtitle, color) {
let html = `
${escapeHtml(title)}`;
if (subtitle) html += ` — ${escapeHtml(subtitle)}`;
html += `
`;
return html;
}
function buildTable(packages, status, withActions, managerInfo) {
const colspan = withActions ? 7 : 6;
let html = `
Package
Nodes
Used
Executions
Last Used
`;
if (withActions) html += `
`;
html += `
`;
for (const pkg of packages) {
const hasNodes = pkg.nodes && pkg.nodes.length > 0;
const lastSeen = pkg.last_seen ? new Date(pkg.last_seen).toLocaleDateString() : "—";
html += `
${hasNodes ? "▶" : " "}
${escapeHtml(pkg.package)}
${pkg.total_nodes}
${pkg.used_nodes}/${pkg.total_nodes}
${pkg.total_executions}
${lastSeen}
`;
if (withActions) {
const eligible = isDisableEligible(pkg, managerInfo);
const cell = eligible
? ``
: `—`;
html += `
${cell}
`;
}
html += `
`;
if (hasNodes) {
html += `
`;
for (const node of pkg.nodes) {
const nLastSeen = node.last_seen ? new Date(node.last_seen).toLocaleDateString() : "—";
html += `
${escapeHtml(node.class_type)}
${node.count}
${nLastSeen}
`;
}
html += `
`;
}
}
html += `
`;
return html;
}
// ---------------------------------------------------------------------------
// ComfyUI Manager integration: disable unused node packages
// ---------------------------------------------------------------------------
// Map of installed packages from ComfyUI Manager, keyed by directory name:
// { : { id, version, files, state }, ... }
// We read the unified list (/customnode/getlist) rather than /customnode/installed
// because only the unified list reports the install *state version* the disable
// endpoint needs: "nightly" for git installs, the semver for registry installs,
// or "unknown". (/customnode/installed returns a raw git commit hash instead,
// which the disable endpoint rejects.) This mirrors what Manager's own UI sends.
// Returns null when the Manager is not installed/reachable, so the disable UI is
// omitted entirely.
async function fetchManagerInfo() {
try {
const resp = await fetch("/customnode/getlist?mode=local&skip_update=true");
if (!resp.ok) return null;
const data = await resp.json();
const packs = data && data.node_packs;
if (!packs || typeof packs !== "object") return null;
const info = {};
for (const [key, v] of Object.entries(packs)) {
if (!v || v.state === "not-installed") continue;
// For installed packs the key is the directory name — matches our package names.
// cnr_id/aux_id are kept so getmappings keys (which may be a registry id or
// repo URL rather than the dir name) can be reconciled in classifyUnresolved.
info[key] = {
id: v.id || key, version: v.version, files: v.files, state: v.state,
cnr_id: v.cnr_id, aux_id: v.aux_id,
};
}
return info;
} catch {
return null;
}
}
// Normalize a repo URL for joining getmappings keys to getlist pack files.
function normalizeRepoUrl(url) {
return String(url || "").trim().toLowerCase().replace(/\.git$/, "").replace(/\/+$/, "");
}
// Join Manager's node->pack mappings with the disabled packs from getlist.
// mappings: { : [ [class_type,...], {title_aux} ] } (from getmappings)
// managerInfo: { : {id,version,files,state,title?} } (from fetchManagerInfo)
// Returns [{ class_type, pack, title, info }] for disabled packs only.
function buildDisabledCatalog(mappings, managerInfo) {
const byUrl = {};
for (const [url, entry] of Object.entries(mappings || {})) {
const list = entry && entry[0];
if (Array.isArray(list)) byUrl[normalizeRepoUrl(url)] = list;
}
const catalog = [];
for (const [dir, info] of Object.entries(managerInfo || {})) {
if (!info || info.state !== "disabled") continue;
const urls = (info.files && info.files.length ? info.files : [info.repository]).filter(Boolean);
let nodes = null;
for (const u of urls) {
const hit = byUrl[normalizeRepoUrl(u)];
if (hit) { nodes = hit; break; }
}
if (!nodes) { console.debug("[Node Stats] no node map for disabled pack", dir); continue; }
const title = info.title || dir;
for (const ct of nodes) catalog.push({ class_type: ct, pack: dir, title, info });
}
return catalog;
}
let _disabledCatalog = null; // cached for the session
async function ensureDisabledCatalog(forceRefresh = false) {
if (_disabledCatalog && !forceRefresh) return _disabledCatalog;
const managerInfo = await fetchManagerInfo();
if (!managerInfo) return null; // Manager absent
let mappings = {};
try {
const r = await fetch("/customnode/getmappings?mode=local");
if (r.ok) mappings = await r.json();
} catch { /* fall through -> empty catalog */ }
_disabledCatalog = buildDisabledCatalog(mappings, managerInfo);
return _disabledCatalog;
}
// Rank a catalog entry against a lowercased query. Lower = better; null = no match.
// class_type prefix (0) < class_type word-start (1) < class_type substring (2)
// < pack-name match (3). No match -> null.
function scoreEntry(entry, q) {
const name = entry.class_type.toLowerCase();
if (name.startsWith(q)) return 0;
if (name.split(/[\s_\-./]/).some((w) => w.startsWith(q))) return 1;
if (name.includes(q)) return 2;
if (entry.pack.toLowerCase().includes(q)) return 3;
return null;
}
// Filter + rank a catalog. Returns { rows, total } where rows is capped at limit.
function filterCatalog(catalog, query, limit = 50) {
const q = String(query || "").trim().toLowerCase();
if (!q) return { rows: [], total: 0 };
const scored = [];
for (const e of catalog) {
const s = scoreEntry(e, q);
if (s !== null) scored.push([s, e]);
}
scored.sort((a, b) => a[0] - b[0] || a[1].class_type.localeCompare(b[1].class_type));
return { rows: scored.slice(0, limit).map((x) => x[1]), total: scored.length };
}
// Split unresolved node types into packages that are installed-but-disabled
// (re-enable to use) vs not installed (install via Manager). Reconciles
// ComfyUI Manager's getmappings (class_type -> pack key) against getlist state.
async function classifyUnresolved(types) {
if (!types.length) return { disabled: [], missing: [] };
let mappings = {}, managerInfo = null;
try {
const [mResp, gi] = await Promise.all([
fetch("/customnode/getmappings?mode=local"),
fetchManagerInfo(), // getlist -> {dir: {id, cnr_id, aux_id, version, files, state}}
]);
if (mResp.ok) mappings = await mResp.json();
managerInfo = gi;
} catch { /* manager absent */ }
// class_type -> packKey. getmappings value is [ [class_types...], {meta} ];
// packKey is a directory name OR a repo/gist URL depending on the pack.
const typeToPack = {};
for (const [packKey, entry] of Object.entries(mappings)) {
for (const ct of (entry?.[0] || [])) typeToPack[ct] = packKey;
}
// Index installed/disabled packs by every identifier they expose (dir name,
// id, cnr_id, aux_id, and each repo URL) so a getmappings key in any of those
// forms resolves. URLs are normalized (drop trailing slash / .git, lowercase).
const norm = (s) => String(s).trim().replace(/\/+$/, "").replace(/\.git$/i, "").toLowerCase();
const byAnyKey = {};
if (managerInfo) for (const [dir, info] of Object.entries(managerInfo)) {
const rec = { ...info, _dir: dir };
byAnyKey[norm(dir)] = rec;
for (const k of [info.id, info.cnr_id, info.aux_id]) if (k) byAnyKey[norm(k)] = rec;
for (const f of (info.files || [])) if (f) byAnyKey[norm(f)] = rec;
}
const disabled = [], missing = [];
for (const ct of types) {
const packKey = typeToPack[ct];
const info = packKey ? byAnyKey[norm(packKey)] : null;
if (info && info.state === "disabled") disabled.push({ type: ct, pkg: info._dir, info });
else missing.push({ type: ct, pkg: packKey || null });
}
return { disabled, missing };
}
// Build the payload ComfyUI Manager's /manager/queue/disable expects, mirroring
// Manager's own frontend: id = directory name, version = install state
// ("nightly" / semver / "unknown"), and files (repo URL) only for "unknown".
function disablePayload(dirName, info) {
const payload = { id: info.id || dirName, version: info.version, ui_id: dirName };
if (info.version === "unknown") {
payload.files = info.files && info.files.length ? info.files : [dirName];
}
return payload;
}
function wireDisableButtons(dialog, managerInfo) {
if (!managerInfo) return;
dialog.querySelectorAll(".ns-disable-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
handleDisable([btn.dataset.pkg], dialog, managerInfo);
});
});
dialog.querySelectorAll(".ns-disable-all-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
let names = [];
try { names = JSON.parse(btn.dataset.pkgs); } catch { names = []; }
handleDisable(names, dialog, managerInfo);
});
});
}
// Wire the Workflow tab's enable/install buttons. Handlers are filled in by the
// enable (Task 10) and install (Task 11) steps.
function wireWorkflowButtons(dialog) {
dialog.querySelectorAll(".ns-enable-temp-btn").forEach((b) =>
b.addEventListener("click", (e) => { e.stopPropagation(); handleEnable(b.dataset.pkg, true, dialog); }));
dialog.querySelectorAll(".ns-enable-perm-btn").forEach((b) =>
b.addEventListener("click", (e) => { e.stopPropagation(); handleEnable(b.dataset.pkg, false, dialog); }));
dialog.querySelectorAll(".ns-install-btn").forEach((b) =>
b.addEventListener("click", (e) => { e.stopPropagation(); handleInstall(b.dataset.pkg, dialog); }));
}
async function handleDisable(pkgNames, dialog, managerInfo) {
// Only act on packages Manager still reports as active (guards against
// double-clicks and stale buttons after a partial batch).
pkgNames = pkgNames.filter((n) => managerInfo[n] && managerInfo[n].state !== "disabled");
if (pkgNames.length === 0) return;
const what = pkgNames.length === 1 ? `"${pkgNames[0]}"` : `${pkgNames.length} packages`;
const confirmMsg =
`Disable ${what} via ComfyUI Manager?\n\n` +
`They will be moved to custom_nodes/.disabled and a ComfyUI restart is ` +
`required to take effect. You can re-enable them anytime from ComfyUI Manager.`;
if (!confirm(confirmMsg)) return;
setDisableButtonsBusy(dialog, true);
try {
const pre = await fetch("/manager/queue/status").then((r) => (r.ok ? r.json() : null)).catch(() => null);
if (pre && pre.is_processing) {
notify("ComfyUI Manager is busy. Please try again in a moment.", "warn");
setDisableButtonsBusy(dialog, false);
return;
}
const payloads = pkgNames.map((n) => disablePayload(n, managerInfo[n]));
await runManagerDisable(payloads);
// Reconcile against Manager's actual state: a package is considered
// disabled only if it's no longer reported as active on disk.
const after = await fetchManagerInfo();
const isStillActive = (n) => after && after[n] && after[n].state !== "disabled";
const succeeded = after ? pkgNames.filter((n) => !isStillActive(n)) : pkgNames;
const failed = pkgNames.filter((n) => !succeeded.includes(n));
succeeded.forEach((n) => { if (managerInfo[n]) managerInfo[n].state = "disabled"; });
markPackagesDisabled(dialog, succeeded);
updateBulkButtons(dialog, managerInfo);
if (succeeded.length > 0) {
showRestartBanner(dialog);
notify(`Disabled ${succeeded.length} package${succeeded.length !== 1 ? "s" : ""}. Restart ComfyUI to apply.`, "success");
}
if (failed.length > 0) {
notify(`ComfyUI Manager could not disable: ${failed.join(", ")}`, "error");
}
} catch (e) {
notify("Failed to disable: " + e.message, "error");
} finally {
setDisableButtonsBusy(dialog, false);
}
}
// Queue the disable tasks and run them, then wait for the Manager worker to
// finish. /manager/queue/start returns 201 if a worker is already running.
async function runManagerDisable(payloads) {
await fetch("/manager/queue/reset", { method: "POST", headers: { "Content-Type": "application/json" } });
for (const payload of payloads) {
const r = await fetch("/manager/queue/disable", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!r.ok) throw new Error(`disable request failed (HTTP ${r.status})`);
}
const start = await fetch("/manager/queue/start", { method: "POST", headers: { "Content-Type": "application/json" } });
if (!start.ok && start.status !== 201) throw new Error(`queue start failed (HTTP ${start.status})`);
await waitForQueue();
}
// Re-enable a disabled pack via ComfyUI Manager (confirmed against the live
// server and ComfyUI-Manager's manager_server.py / manager_core.py). Two routes
// through /manager/queue/install, both ending in unified_enable (a dir move out
// of .disabled — never a re-clone):
// • version != "unknown" (nightly/semver): skip_post_install takes the fast
// path, unified_enable(id) is called and the route returns before reading
// channel/mode/files. Load-bearing: id, version, skip_post_install.
// • version == "unknown": queues an install task; install_by_id sees the pack
// is_disabled and calls unified_enable. Needs files (repo URL), channel, mode.
// selected_version always mirrors version, so the "invalid request" arm (version
// set but selected_version=="unknown") is never hit. One payload covers both.
function enablePayload(dirName, info) {
return {
id: info.id || dirName,
version: info.version,
files: info.files,
channel: "default",
mode: "cache",
skip_post_install: true,
selected_version: info.version,
ui_id: dirName,
};
}
// Whether ComfyUI Manager is mid-operation. Used to avoid resetting its queue
// out from under an in-progress install/disable (the manual disable flow guards
// the same way before calling runManagerDisable).
async function managerIsBusy() {
try {
const r = await fetch("/manager/queue/status");
if (!r.ok) return false;
const st = await r.json();
return !!(st && st.is_processing);
} catch {
return false;
}
}
async function runManagerEnable(payload) {
await fetch("/manager/queue/reset", { method: "POST", headers: { "Content-Type": "application/json" } });
const r = await fetch("/manager/queue/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!r.ok) throw new Error(`enable request failed (HTTP ${r.status})`);
const start = await fetch("/manager/queue/start", { method: "POST", headers: { "Content-Type": "application/json" } });
if (!start.ok && start.status !== 201) throw new Error(`queue start failed (HTTP ${start.status})`);
await waitForQueue();
}
// Shared enable core used by the Workflow tab and the mirror search palette.
// Performs the Manager enable + trial bookkeeping + success toast.
// Returns true on success, false if Manager was busy. Throws on failure.
// Caller owns its own busy UI and restart affordance.
async function enablePackage(pkg, info, temporary) {
if (!info) throw new Error("no enable info for " + pkg);
if (await managerIsBusy()) {
notify("ComfyUI Manager is busy. Please try again in a moment.", "warn");
return false;
}
await runManagerEnable(enablePayload(pkg, info));
const route = temporary ? "/nodes-stats/trials/start" : "/nodes-stats/trials/stop";
await fetch(route, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ package: pkg }),
});
notify(`Enabled ${pkg}${temporary ? " for a 7-day trial" : ""}. Restart ComfyUI to apply.`, "success");
return true;
}
// Enable a disabled package, optionally under a temporary trial. A permanent
// enable clears any existing trial row so the package is never auto-disabled.
async function handleEnable(pkg, temporary, dialog) {
const entry = _lastWorkflowScan.disabled.find((d) => d.pkg === pkg);
const info = entry && entry.info;
if (!info) return;
setWorkflowButtonsBusy(dialog, true);
try {
if (await enablePackage(pkg, info, temporary)) {
entry.info.state = "enabled";
showRestartBanner(dialog);
}
} catch (e) {
notify("Failed to enable: " + e.message, "error");
} finally {
setWorkflowButtonsBusy(dialog, false);
}
}
// ---------------------------------------------------------------------------
// Mirror search: a standalone palette over nodes of currently-disabled packs
// ---------------------------------------------------------------------------
async function openMirrorSearch() {
const existing = document.getElementById("nodes-stats-mirror");
if (existing) { existing.querySelector("#ns-mirror-input")?.focus(); return; }
const overlay = document.createElement("div");
overlay.id = "nodes-stats-mirror";
overlay.style.cssText =
"position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:10001;display:flex;align-items:flex-start;justify-content:center;";
overlay.addEventListener("click", (e) => { if (e.target === overlay) overlay.remove(); });
overlay.addEventListener("keydown", (e) => { if (e.key === "Escape") overlay.remove(); });
const box = document.createElement("div");
box.style.cssText =
"margin-top:10vh;background:#1e1e1e;color:#ddd;border:1px solid #444;border-radius:8px;width:90%;max-width:640px;max-height:70vh;display:flex;flex-direction:column;font-family:monospace;font-size:13px;overflow:hidden;";
box.innerHTML = `
`;
overlay.appendChild(box);
document.body.appendChild(overlay);
const input = box.querySelector("#ns-mirror-input");
const results = box.querySelector("#ns-mirror-results");
const footer = box.querySelector("#ns-mirror-footer");
footer.textContent = "loading disabled-node catalog…";
let catalog = await ensureDisabledCatalog();
if (catalog === null) { footer.textContent = "ComfyUI Manager not available."; return; }
if (catalog.length === 0) { footer.textContent = "No disabled packages — nothing to search."; return; }
const packCount = new Set(catalog.map((e) => e.pack)).size;
footer.textContent = `${catalog.length} nodes across ${packCount} disabled packs · enabling needs a restart`;
function render() {
const { rows, total } = filterCatalog(catalog, input.value);
if (!input.value.trim()) {
results.innerHTML = `
Type to search ${catalog.length} nodes in ${packCount} disabled packs.
No disabled nodes match “${escapeHtml(input.value)}”.
`; return; }
let html = "";
for (const e of rows) {
html += `
${escapeHtml(e.class_type)}
${escapeHtml(e.pack)}
`;
}
if (total > rows.length) html += `
+${total - rows.length} more — refine your search.
`;
results.innerHTML = html;
results.querySelectorAll(".ns-mirror-temp").forEach((b) =>
b.addEventListener("click", () => mirrorEnable(b.dataset.pkg, true, overlay)));
results.querySelectorAll(".ns-mirror-perm").forEach((b) =>
b.addEventListener("click", () => mirrorEnable(b.dataset.pkg, false, overlay)));
}
input.addEventListener("input", render);
box.querySelector("#ns-mirror-refresh").addEventListener("click", async () => {
footer.textContent = "refreshing…";
catalog = await ensureDisabledCatalog(true) || [];
footer.textContent = `${catalog.length} nodes across ${new Set(catalog.map((e)=>e.pack)).size} disabled packs · enabling needs a restart`;
render();
});
render();
input.focus();
}
// Enable from the palette. Marks all rows for the pack as enabled on success.
async function mirrorEnable(pkg, temporary, overlay) {
const entry = (_disabledCatalog || []).find((e) => e.pack === pkg);
const info = entry && entry.info;
if (!info) return;
overlay.querySelectorAll(".ns-btn").forEach((b) => (b.disabled = true));
try {
if (await enablePackage(pkg, info, temporary)) {
(_disabledCatalog || []).forEach((e) => { if (e.pack === pkg) e.info.state = "enabled"; });
overlay.querySelectorAll(`.ns-mirror-temp[data-pkg="${cssEscape(pkg)}"], .ns-mirror-perm[data-pkg="${cssEscape(pkg)}"]`)
.forEach((b) => { b.replaceWith(Object.assign(document.createElement("span"), { textContent: "✓ enabled · restart", style: "color:#6a6;font-size:11px;" })); });
}
} catch (e) {
notify("Failed to enable: " + e.message, "error");
} finally {
overlay.querySelectorAll(".ns-btn").forEach((b) => (b.disabled = false));
}
}
// Missing packages are deferred to ComfyUI Manager — the design treats "Missing"
// as handled by Manager like always, and Manager already surfaces missing nodes
// on workflow load. We intentionally do NOT replicate install: a not-installed
// pack's exact spec can't be resolved reliably client-side (mode=local getlist
// exposes no cnr_id and an ambiguous version field, so cnr@latest vs git@unknown
// can't be chosen without risking "cannot resolve install target"). Instead open
// Manager's Custom Nodes Manager (which has a built-in Missing filter); if that
// command isn't available in this ComfyUI build, guide the user to it.
async function handleInstall(pkg, dialog) {
let opened = false;
try {
const cmd = app?.extensionManager?.command;
if (cmd && typeof cmd.execute === "function") {
await cmd.execute("Comfy.Manager.CustomNodesManager.ToggleVisibility");
opened = true;
}
} catch { /* fall through to guidance */ }
notify(
opened
? `Opened ComfyUI Manager — choose the "Missing" filter to install ${pkg}.`
: `Install ${pkg} via ComfyUI Manager → "Install Missing Custom Nodes".`,
"info"
);
}
function setWorkflowButtonsBusy(dialog, busy) {
dialog.querySelectorAll(".ns-enable-temp-btn, .ns-enable-perm-btn, .ns-install-btn").forEach((b) => {
b.disabled = busy;
});
}
async function stopTrial(pkg) {
try {
await fetch("/nodes-stats/trials/stop", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ package: pkg }),
});
} catch { /* best-effort; row ages out next session */ }
}
// On UI load, disable any trial package whose 7 distinct boot-days elapsed with
// no use (the backend marks it expired). The disable goes through ComfyUI
// Manager exactly like a manual disable; the trial row is then cleared. Inert
// when Manager is absent. A package already disabled on disk just clears its row.
async function processExpiredTrials() {
let trials = [];
try {
const r = await fetch("/nodes-stats/trials");
if (r.ok) trials = await r.json();
} catch { return; }
const expired = trials.filter((t) => t.expired);
if (!expired.length) return;
const mgr = await fetchManagerInfo();
if (!mgr) return; // Manager unavailable — leave rows for a later session
// Don't reset Manager's queue out from under an in-progress operation
// (e.g. startup install work); the expired rows persist and retry next session.
if (await managerIsBusy()) return;
const done = [];
for (const t of expired) {
const info = mgr[t.package];
if (!info || info.state === "disabled") {
await stopTrial(t.package);
done.push(t.package);
continue;
}
try {
await runManagerDisable([disablePayload(t.package, info)]);
await stopTrial(t.package);
done.push(t.package);
} catch { /* keep the row; retry next session */ }
}
if (done.length) {
notify(`Auto-disabled ${done.length} unused trial package(s). Restart ComfyUI to apply.`, "info");
}
}
async function waitForQueue(timeoutMs = 60000) {
const deadline = Date.now() + timeoutMs;
await sleep(300);
while (Date.now() < deadline) {
let st = null;
try {
const r = await fetch("/manager/queue/status");
if (r.ok) st = await r.json();
} catch { /* transient; retry */ }
if (st && !st.is_processing && st.in_progress_count === 0) return;
await sleep(500);
}
throw new Error("timed out waiting for ComfyUI Manager");
}
const sleep = (ms) => new Promise((res) => setTimeout(res, ms));
function setDisableButtonsBusy(dialog, busy) {
dialog.querySelectorAll(".ns-disable-btn, .ns-disable-all-btn").forEach((b) => {
b.disabled = busy;
});
}
function markPackagesDisabled(dialog, pkgNames) {
for (const name of pkgNames) {
const cell = dialog.querySelector(`.ns-action-cell[data-pkg="${cssEscape(name)}"]`);
if (cell) {
cell.innerHTML = `✓ disabled · restart`;
cell.closest("tr")?.classList.add("ns-disabled-row");
}
}
}
// Recompute "Disable all (N)" counts after a batch; hide buttons with nothing
// left to disable.
function updateBulkButtons(dialog, managerInfo) {
dialog.querySelectorAll(".ns-disable-all-btn").forEach((btn) => {
let names = [];
try { names = JSON.parse(btn.dataset.pkgs); } catch { names = []; }
const remaining = names.filter((n) => managerInfo[n] && managerInfo[n].state !== "disabled");
if (remaining.length === 0) {
btn.style.display = "none";
} else {
btn.dataset.pkgs = JSON.stringify(remaining);
btn.textContent = `Disable all (${remaining.length})`;
}
});
}
function showRestartBanner(dialog) {
if (dialog.querySelector("#ns-restart-banner")) return;
const banner = document.createElement("div");
banner.id = "ns-restart-banner";
banner.style.cssText =
"display:flex;align-items:center;justify-content:space-between;gap:12px;background:#2a2215;border:1px solid #a83;border-radius:4px;padding:10px 14px;margin-bottom:16px;";
banner.innerHTML =
`Changes applied on disk. Restart ComfyUI to unload disabled packages.`;
const tabs = dialog.querySelector("#ns-tabs");
tabs ? tabs.before(banner) : dialog.prepend(banner);
banner.querySelector("#ns-restart-btn").addEventListener("click", rebootComfy);
banner.querySelector("#ns-restart-dismiss").addEventListener("click", () => banner.remove());
}
async function rebootComfy() {
if (!confirm("Restart ComfyUI now? The server will go down briefly and the page will reconnect.")) return;
notify("Restarting ComfyUI…", "info");
try {
await fetch("/manager/reboot", { method: "POST", headers: { "Content-Type": "application/json" } });
} catch {
// The reboot tears down the connection, so a network error here is expected.
}
}
function notify(detail, severity) {
try {
const toast = app?.extensionManager?.toast;
if (toast && typeof toast.add === "function") {
toast.add({ severity: severity === "warn" ? "warn" : severity, summary: "Node Stats", detail, life: 5000 });
return;
}
} catch { /* fall through to console/alert */ }
if (severity === "error") alert(detail);
else console.log("[Node Stats] " + detail);
}
// ---------------------------------------------------------------------------
// Easter egg
// ---------------------------------------------------------------------------
// Internal: builds celebratory overlay for top contributors
function showPodium(top3, overlay) {
const existing = document.getElementById("nodes-stats-podium");
if (existing) { existing.remove(); return; }
const colors = ["#FFD700", "#C0C0C0", "#CD7F32"];
const heights = [160, 120, 90];
const order = [1, 0, 2];
// SVG characters: champion with cape, cool runner-up, happy bronze
const characters = [
// Gold: flexing champion with crown and cape
``,
// Silver: sunglasses dude, arms crossed
``,
// Bronze: happy little guy waving
``,
];
const podium = document.createElement("div");
podium.id = "nodes-stats-podium";
podium.style.cssText =
"position:absolute;top:0;left:0;width:100%;height:100%;background:radial-gradient(ellipse at center,#1a1a2e 0%,#0a0a12 100%);display:flex;flex-direction:column;align-items:center;justify-content:center;border-radius:8px;z-index:1;cursor:pointer;overflow:hidden;";
podium.addEventListener("click", () => podium.remove());
// Sparkle particles
let sparkles = "";
for (let i = 0; i < 20; i++) {
const x = Math.random() * 100;
const y = Math.random() * 60;
const d = (1 + Math.random() * 2).toFixed(1);
const o = (0.3 + Math.random() * 0.7).toFixed(2);
sparkles += ``;
}
let html = ``;
html += sparkles;
// Trophy title
html += `
Hall of Fame
`;
// Podium blocks
html += `
`;
for (const i of order) {
const node = top3[i];
if (!node) continue;
const isGold = i === 0;
const w = isGold ? 170 : 140;
const floatDelay = [0, 0.3, 0.6][i];
html += `
${characters[i]}
${escapeHtml(node.class_type)}
${escapeHtml(node.pkg)}
${i + 1}${["st","nd","rd"][i]}
${node.count.toLocaleString()}x
`;
}
html += `
`;
html += `
click to dismiss
`;
podium.innerHTML = html;
overlay.querySelector("div").style.position = "relative";
overlay.querySelector("div").appendChild(podium);
}
function escapeHtml(str) {
const div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
}
// Escape a value for use inside a double-quoted HTML attribute.
function escapeAttr(str) {
return escapeHtml(str).replace(/"/g, """);
}
// Escape a string for use in a CSS attribute selector.
function cssEscape(str) {
return window.CSS && CSS.escape ? CSS.escape(str) : String(str).replace(/["\\]/g, "\\$&");
}