import { app } from "../../scripts/app.js"; // Bar chart with nodes icon const STATS_ICON = ` `; 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); } } }, }); async function showStatsDialog() { let data, modelData; try { const [pkgResp, modelResp] = await Promise.all([ fetch("/nodes-stats/packages"), fetch("/nodes-stats/models"), ]); 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(); 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 = `

Node Package Stats

`; // Tab switcher — wired via addEventListener after insertion, no onclick globals html += `
`; // Nodes tab content (existing content, wrapped) html += `
`; html += buildNodesTabContent(custom); 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")); document .getElementById("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" ? "▶" : "▼"; } }); }); // Easter egg: click "used" badge 5 times to show podium let eggClicks = 0; let eggTimer = null; const usedBadge = document.getElementById("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); } }); } } function buildNodesTabContent(custom) { const safeToRemove = custom.filter((p) => p.status === "safe_to_remove"); const considerRemoving = custom.filter((p) => p.status === "consider_removing"); const unusedNew = custom.filter((p) => p.status === "unused_new"); const used = custom.filter((p) => p.status === "used"); const uninstalled = custom.filter((p) => p.status === "uninstalled"); let html = `
${safeToRemove.length} safe to remove
${considerRemoving.length} consider removing
${unusedNew.length} unused <1 month
${used.length} used
`; if (safeToRemove.length > 0) html += sectionHeader("Safe to Remove", "Unused for 2+ months", "#e44") + buildTable(safeToRemove, "safe_to_remove"); if (considerRemoving.length > 0) html += sectionHeader("Consider Removing", "Unused for 1-2 months", "#e90") + buildTable(considerRemoving, "consider_removing"); if (unusedNew.length > 0) html += sectionHeader("Recently Unused", "Unused for less than 1 month", "#68f") + buildTable(unusedNew, "unused_new"); if (used.length > 0) html += sectionHeader("Used", "", "#4a4") + buildTable(used, "used"); if (uninstalled.length > 0) html += sectionHeader("Uninstalled", "Previously tracked, no longer installed", "#555") + buildTable(uninstalled, "uninstalled"); return html; } function buildModelsTabContent(modelData) { // Flatten for summary counts const allModels = modelData.flatMap((g) => g.models); const safeCount = allModels.filter((m) => m.status === "safe_to_remove").length; const considerCount = allModels.filter((m) => m.status === "consider_removing").length; const unusedNewCount = allModels.filter((m) => m.status === "unused_new").length; const usedCount = allModels.filter((m) => m.status === "used").length; let html = `
${safeCount} safe to remove
${considerCount} consider removing
${unusedNewCount} unused <1 month
${usedCount} 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 { bg, hover } = STATUS_COLORS[m.status] || STATUS_COLORS.used; const lastSeen = m.last_seen ? new Date(m.last_seen).toLocaleDateString() : "—"; const statusLabel = { safe_to_remove: { text: "safe to remove", color: "#e44" }, consider_removing: { text: "consider removing", color: "#e90" }, unused_new: { text: "unused <1mo", color: "#68f" }, used: { text: "used", color: "#4a4" }, uninstalled: { text: "uninstalled", color: "#555" }, }[m.status] || { text: m.status, color: "#888" }; html += ``; } html += `
Model Executions Last Used Status
${escapeHtml(m.model_name)} ${m.count} ${lastSeen} ${statusLabel.text}
`; return html; } function sectionHeader(title, subtitle, color) { let html = `

${title}`; if (subtitle) html += ` — ${subtitle}`; html += `

`; return html; } const STATUS_COLORS = { safe_to_remove: { bg: "#2a1515", hover: "#3a2020" }, consider_removing: { bg: "#2a2215", hover: "#3a2e20" }, unused_new: { bg: "#1a1a25", hover: "#252530" }, used: { bg: "#151a15", hover: "#202a20" }, uninstalled: { bg: "#1a1a1a", hover: "#252525" }, }; function buildTable(packages, status) { const { bg: bgColor, hover: hoverColor } = STATUS_COLORS[status] || STATUS_COLORS.used; let 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 (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}
`; return html; } // 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; }