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); } } // 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; }; } }, }); // 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]; } async function onWorkflowLoaded() { const unresolved = unresolvedNodeTypes(); if (unresolved.length) console.log("[Node Stats] unresolved:", unresolved); } async function showStatsDialog() { let data, modelData, managerInfo; try { const [pkgResp, modelResp, mgr] = await Promise.all([ fetch("/nodes-stats/packages"), fetch("/nodes-stats/models"), fetchManagerInfo(), ]); 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 (!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 += ``; dialog.innerHTML = html; overlay.appendChild(dialog); document.body.appendChild(overlay); // Tab switch — local function, no window pollution function switchTab(tab) { dialog.querySelector("#ns-content-nodes").style.display = tab === "nodes" ? "" : "none"; dialog.querySelector("#ns-content-models").style.display = tab === "models" ? "" : "none"; const nodeBtn = dialog.querySelector("#ns-tab-nodes"); const modelBtn = dialog.querySelector("#ns-tab-models"); nodeBtn.style.borderBottomColor = tab === "nodes" ? "#4a4" : "transparent"; nodeBtn.style.color = tab === "nodes" ? "#4a4" : "#888"; nodeBtn.style.fontWeight = tab === "nodes" ? "bold" : "normal"; modelBtn.style.borderBottomColor = tab === "models" ? "#4a4" : "transparent"; modelBtn.style.color = tab === "models" ? "#4a4" : "#888"; modelBtn.style.fontWeight = tab === "models" ? "bold" : "normal"; } dialog.querySelector("#ns-tab-nodes").addEventListener("click", () => switchTab("nodes")); dialog.querySelector("#ns-tab-models").addEventListener("click", () => switchTab("models")); 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); // 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; } 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. info[key] = { id: v.id || key, version: v.version, files: v.files, state: v.state }; } return info; } catch { return null; } } // 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); }); }); } 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(); } 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, "\\$&"); }