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 += ``; // Workflow tab content (missing / disabled nodes in the loaded workflow) 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 = ``; 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 += ``; } html += `
Model Executions Last Used Status
${escapeHtml(m.model_name)} ${m.count} ${lastSeen} ${meta.label}
`; 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 += ``; } html += `
${escapeHtml(d.type)} ${escapeHtml(d.pkg)} ${note}
`; } if (missing.length) { html += sectionHeader("Missing", "Not installed — install via ComfyUI Manager", "#e44"); html += ``; for (const m of missing) { html += ``; } html += `
${escapeHtml(m.type)} ${m.pkg ? escapeHtml(m.pkg) : "unknown"} ${m.pkg ? `` : "—"}
`; } 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 = ``; 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 += ``; if (withActions) { const eligible = isDisableEligible(pkg, managerInfo); const cell = eligible ? `` : ``; html += ``; } html += ``; if (hasNodes) { html += ``; } } html += `
Package Nodes Used Executions Last Used
${hasNodes ? "▶" : " "} ${escapeHtml(pkg.package)} ${pkg.total_nodes} ${pkg.used_nodes}/${pkg.total_nodes} ${pkg.total_executions} ${lastSeen}${cell}
`; 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 an identifier (repo URL, dir name, or registry id) for joining // getmappings keys to getlist packs. Same ordering as classifyUnresolved's norm. function normalizeRepoUrl(url) { return String(url || "").trim().replace(/\/+$/, "").replace(/\.git$/i, "").toLowerCase(); } // Join Manager's node->pack mappings with the disabled packs from getlist. // mappings: { : [ [class_type,...], {title_aux} ] } (from getmappings) // managerInfo: { : {id,version,files,state,cnr_id,aux_id} } (from fetchManagerInfo) // getmappings keys come in several forms (dir name, registry id, repo/gist URL), // and Manager keys the node map by dir/id far more often than by URL — so we // resolve each key against EVERY identifier a pack exposes, exactly as // classifyUnresolved does. Matching repo URLs alone misses the vast majority of // packs. Returns [{ class_type, pack, title, info }] for disabled packs only. function buildDisabledCatalog(mappings, managerInfo) { const byAnyKey = {}; for (const [dir, info] of Object.entries(managerInfo || {})) { if (!info) continue; const rec = { dir, info }; byAnyKey[normalizeRepoUrl(dir)] = rec; for (const k of [info.id, info.cnr_id, info.aux_id]) if (k) byAnyKey[normalizeRepoUrl(k)] = rec; for (const f of (info.files || [])) if (f) byAnyKey[normalizeRepoUrl(f)] = rec; } const catalog = []; const seen = new Set(); const packMeta = {}; // dir -> shared { pack, title, author, description, repo, version, info, nodes:[] } for (const [packKey, entry] of Object.entries(mappings || {})) { const rec = byAnyKey[normalizeRepoUrl(packKey)]; if (!rec || rec.info.state !== "disabled") continue; const list = entry && entry[0]; if (!Array.isArray(list)) continue; const m = (entry && entry[1]) || {}; let meta = packMeta[rec.dir]; if (!meta) { const repo = (rec.info.files || []).find((f) => /^https?:\/\//i.test(f)) || ""; meta = packMeta[rec.dir] = { pack: rec.dir, title: m.title || m.title_aux || rec.info.title || rec.dir, author: m.author || "", description: m.description || "", repo, version: rec.info.version || "", info: rec.info, nodes: [], }; } for (const ct of list) { const dedup = rec.dir + "\n" + ct; if (seen.has(dedup)) continue; seen.add(dedup); meta.nodes.push(ct); catalog.push({ class_type: ct, pack: rec.dir, title: meta.title, info: rec.info, meta }); } } for (const meta of Object.values(packMeta)) meta.nodes.sort((a, b) => a.localeCompare(b)); 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:880px;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 preview = box.querySelector("#ns-mirror-preview"); const footer = box.querySelector("#ns-mirror-footer"); let currentRows = []; let activeIndex = -1; function clearPreview(msg) { preview.innerHTML = `
${escapeHtml(msg || "Hover a result to preview its package.")}
`; } // Preview panel for the active row. We can't render a real node graphic (the // pack is disabled, so its definition isn't loaded), so we show the pack // metadata we do have: title/author/description + the sibling nodes in the pack. function renderPreview(entry) { if (!entry) { clearPreview(); return; } const m = entry.meta || {}; const sibs = m.nodes || []; const CAP = 60; const shown = sibs.slice(0, CAP); const sibHtml = shown.map((n) => { const me = n === entry.class_type; return `
${me ? "▸ " : "· "}${escapeHtml(n)}
`; }).join("") + (sibs.length > shown.length ? `
+${sibs.length - shown.length} more
` : ""); const meta = [`pack${escapeHtml(entry.pack)}`]; if (m.author) meta.push(`author${escapeHtml(m.author)}`); if (m.version) meta.push(`version${escapeHtml(String(m.version))}`); preview.innerHTML = `
${escapeHtml(entry.class_type)}
${meta.join("")}
${m.description ? `
${escapeHtml(m.description)}
` : ""} ${m.repo ? `
${escapeHtml(m.repo)}
` : ""}
${sibs.length} node${sibs.length !== 1 ? "s" : ""} in this pack
${sibHtml}
`; preview.querySelectorAll(".ns-mirror-temp").forEach((b) => b.addEventListener("click", () => mirrorEnable(b.dataset.pkg, true, overlay))); preview.querySelectorAll(".ns-mirror-perm").forEach((b) => b.addEventListener("click", () => mirrorEnable(b.dataset.pkg, false, overlay))); } function setActive(i) { if (!currentRows.length) { activeIndex = -1; clearPreview(); return; } activeIndex = Math.max(0, Math.min(i, currentRows.length - 1)); const els = results.querySelectorAll(".ns-mrow"); els.forEach((el, idx) => el.classList.toggle("active", idx === activeIndex)); els[activeIndex]?.scrollIntoView({ block: "nearest" }); renderPreview(currentRows[activeIndex]); } footer.textContent = "loading disabled-node catalog…"; clearPreview("Loading…"); let catalog = await ensureDisabledCatalog(); if (catalog === null) { footer.textContent = "ComfyUI Manager not available."; clearPreview(" "); return; } if (catalog.length === 0) { footer.textContent = "No disabled packages — nothing to search."; clearPreview(" "); 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); currentRows = rows; activeIndex = -1; if (!input.value.trim()) { results.innerHTML = `
Type to search ${catalog.length} nodes in ${packCount} disabled packs.
`; clearPreview(); return; } if (total === 0) { results.innerHTML = `
No disabled nodes match “${escapeHtml(input.value)}”.
`; clearPreview("No match."); 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))); results.querySelectorAll(".ns-mrow").forEach((el, i) => el.addEventListener("mouseenter", () => setActive(i))); setActive(0); } input.addEventListener("input", render); input.addEventListener("keydown", (e) => { if (e.key === "ArrowDown") { e.preventDefault(); setActive(activeIndex + 1); } else if (e.key === "ArrowUp") { e.preventDefault(); setActive(activeIndex - 1); } }); 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 ` GOAT `, // Silver: sunglasses dude, arms crossed ` cool. `, // Bronze: happy little guy waving ` yay! `, ]; 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 += `
#1
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, "\\$&"); }