commit 25f1729f234807372af8b6d5ffb86954a4c0f622 Author: Ethanfel Date: Sun Feb 22 13:29:08 2026 +0100 Initial commit: ComfyUI node usage stats tracker Tracks every node used in every prompt submission via SQLite, maps class_types to source packages, and exposes API endpoints and a frontend dialog for viewing per-package usage stats. Co-Authored-By: Claude Opus 4.6 diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..4517963 --- /dev/null +++ b/__init__.py @@ -0,0 +1,68 @@ +import logging + +from aiohttp import web +from server import PromptServer + +from .mapper import NodePackageMapper +from .tracker import UsageTracker + +logger = logging.getLogger(__name__) + +NODE_CLASS_MAPPINGS = {} +WEB_DIRECTORY = "./js" + +mapper = NodePackageMapper() +tracker = UsageTracker() + + +def on_prompt_handler(json_data): + """Called on every prompt submission. Extracts class_types and records usage.""" + try: + prompt = json_data.get("prompt", {}) + class_types = set() + for node_id, node_data in prompt.items(): + ct = node_data.get("class_type") + if ct: + class_types.add(ct) + if class_types: + tracker.record_usage(class_types, mapper) + except Exception: + logger.warning("nodes-stats: error recording usage", exc_info=True) + return json_data + + +PromptServer.instance.add_on_prompt_handler(on_prompt_handler) + + +routes = PromptServer.instance.routes + + +@routes.get("/nodes-stats/packages") +async def get_package_stats(request): + try: + stats = tracker.get_package_stats(mapper) + return web.json_response(stats) + except Exception: + logger.error("nodes-stats: error getting package stats", exc_info=True) + return web.json_response({"error": "internal error"}, status=500) + + +@routes.get("/nodes-stats/usage") +async def get_node_stats(request): + try: + stats = tracker.get_node_stats() + return web.json_response(stats) + except Exception: + logger.error("nodes-stats: error getting node stats", exc_info=True) + return web.json_response({"error": "internal error"}, status=500) + + +@routes.post("/nodes-stats/reset") +async def reset_stats(request): + try: + tracker.reset() + mapper.invalidate() + return web.json_response({"status": "ok"}) + except Exception: + logger.error("nodes-stats: error resetting stats", exc_info=True) + return web.json_response({"error": "internal error"}, status=500) diff --git a/js/nodes_stats.js b/js/nodes_stats.js new file mode 100644 index 0000000..77ea0fe --- /dev/null +++ b/js/nodes_stats.js @@ -0,0 +1,178 @@ +import { app } from "../../scripts/app.js"; + +app.registerExtension({ + name: "comfyui.nodes_stats", + + async setup() { + const btn = document.createElement("button"); + btn.textContent = "Node Stats"; + btn.style.cssText = + "font-size:14px;padding:4px 12px;cursor:pointer;border:none;border-radius:4px;background:#333;color:#fff;"; + btn.addEventListener("click", () => showStatsDialog()); + + // Insert into ComfyUI menu bar + const menu = document.querySelector(".comfy-menu .comfy-menu-btns") || + document.querySelector(".comfy-menu"); + if (menu) { + menu.appendChild(btn); + } else { + // Fallback: wait for menu to appear + const observer = new MutationObserver(() => { + const m = document.querySelector(".comfy-menu .comfy-menu-btns") || + document.querySelector(".comfy-menu"); + if (m) { + m.appendChild(btn); + observer.disconnect(); + } + }); + observer.observe(document.body, { childList: true, subtree: true }); + } + }, +}); + +async function showStatsDialog() { + let data; + try { + const resp = await fetch("/nodes-stats/packages"); + if (!resp.ok) { + alert("Failed to load node stats: HTTP " + resp.status); + return; + } + data = await resp.json(); + if (!Array.isArray(data)) { + alert("Failed to load node stats: unexpected response format"); + return; + } + } catch (e) { + alert("Failed to load node stats: " + e.message); + return; + } + + // 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;"; + + const neverUsed = data.filter( + (p) => p.never_used && p.package !== "__builtin__" + ); + const used = data.filter( + (p) => !p.never_used && p.package !== "__builtin__" + ); + + let html = `
+

Node Package Stats

+ +
`; + + html += `
+
+ ${neverUsed.length} + never used +
+
+ ${used.length} + used +
+
`; + + if (neverUsed.length > 0) { + html += `

Never Used — Safe to Remove

`; + html += buildTable(neverUsed, true); + } + + if (used.length > 0) { + html += `

Used Packages

`; + html += buildTable(used, false); + } + + dialog.innerHTML = html; + overlay.appendChild(dialog); + document.body.appendChild(overlay); + + 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" ? "▶" : "▼"; + } + }); + }); +} + +function buildTable(packages, isNeverUsed) { + const bgColor = isNeverUsed ? "#2a1515" : "#151a15"; + const hoverColor = isNeverUsed ? "#3a2020" : "#202a20"; + + 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 += `
PackageNodesUsedExecutionsLast Used
${hasNodes ? "▶" : " "}${escapeHtml(pkg.package)}${pkg.total_nodes}${pkg.used_nodes}/${pkg.total_nodes}${pkg.total_executions}${lastSeen}
`; + return html; +} + +function escapeHtml(str) { + const div = document.createElement("div"); + div.textContent = str; + return div.innerHTML; +} diff --git a/mapper.py b/mapper.py new file mode 100644 index 0000000..2c4f6b6 --- /dev/null +++ b/mapper.py @@ -0,0 +1,68 @@ +import logging +import os + +logger = logging.getLogger(__name__) + + +class NodePackageMapper: + """Maps node class_type names to their source package.""" + + def __init__(self): + self._map = None + + def _build_map(self): + import nodes + + self._map = {} + for class_type, node_cls in nodes.NODE_CLASS_MAPPINGS.items(): + module = getattr(node_cls, "RELATIVE_PYTHON_MODULE", None) + if module: + # "custom_nodes.PackageName" -> "PackageName" + # "comfy_extras.nodes_xyz" -> "__builtin__" + # "comfy_api_nodes.xyz" -> "__builtin__" + parts = module.split(".", 1) + if parts[0] == "custom_nodes" and len(parts) > 1: + self._map[class_type] = parts[1] + else: + self._map[class_type] = "__builtin__" + else: + self._map[class_type] = "__builtin__" + + @property + def mapping(self): + if self._map is None: + self._build_map() + return self._map + + def get_package(self, class_type): + return self.mapping.get(class_type, "__unknown__") + + def get_all_packages(self): + """Return set of all known package names, including zero-node packages.""" + packages = set(self.mapping.values()) + + try: + import nodes + import folder_paths + + # Get all custom_nodes directories to filter LOADED_MODULE_DIRS + custom_node_dirs = set() + for d in folder_paths.get_folder_paths("custom_nodes"): + custom_node_dirs.add(os.path.normpath(d)) + + # LOADED_MODULE_DIRS contains ALL modules (custom nodes, comfy_extras, + # comfy_api_nodes). We only want custom node packages, identified by + # their directory being directly inside a custom_nodes directory. + for module_name, module_dir in nodes.LOADED_MODULE_DIRS.items(): + parent_dir = os.path.normpath(os.path.dirname(module_dir)) + if parent_dir in custom_node_dirs: + packages.add(os.path.basename(module_dir)) + except Exception: + logger.warning("Could not read LOADED_MODULE_DIRS", exc_info=True) + + packages.discard("__builtin__") + return packages + + def invalidate(self): + """Force rebuild on next access (e.g. after node reload).""" + self._map = None diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fad3334 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "comfyui-nodes-stats" +description = "Track usage statistics for all ComfyUI nodes and packages" +version = "1.0.0" +license = "MIT" + +[project.urls] +Repository = "https://github.com/placeholder/comfyui-nodes-stats" + +[tool.comfy] +PublisherId = "" +DisplayName = "Node Usage Stats" +Icon = "" diff --git a/tracker.py b/tracker.py new file mode 100644 index 0000000..e392d91 --- /dev/null +++ b/tracker.py @@ -0,0 +1,152 @@ +import json +import logging +import os +import sqlite3 +import threading +from datetime import datetime, timezone + +logger = logging.getLogger(__name__) + +DB_PATH = os.path.join(os.path.dirname(__file__), "usage_stats.db") + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS node_usage ( + class_type TEXT PRIMARY KEY, + package TEXT NOT NULL, + count INTEGER NOT NULL DEFAULT 0, + first_seen TEXT NOT NULL, + last_seen TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS prompt_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL, + class_types TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_node_usage_package ON node_usage(package); +CREATE INDEX IF NOT EXISTS idx_prompt_log_timestamp ON prompt_log(timestamp); +""" + + +class UsageTracker: + def __init__(self, db_path=DB_PATH): + self._db_path = db_path + self._lock = threading.Lock() + self._init_db() + + def _init_db(self): + with self._lock: + conn = sqlite3.connect(self._db_path) + try: + conn.executescript(SCHEMA) + conn.commit() + finally: + conn.close() + + def _connect(self): + return sqlite3.connect(self._db_path) + + def record_usage(self, class_types, mapper): + """Record usage of a set of class_types from a single prompt execution.""" + now = datetime.now(timezone.utc).isoformat() + with self._lock: + conn = self._connect() + try: + for ct in class_types: + package = mapper.get_package(ct) + conn.execute( + """INSERT INTO node_usage (class_type, package, count, first_seen, last_seen) + VALUES (?, ?, 1, ?, ?) + ON CONFLICT(class_type) DO UPDATE SET + count = count + 1, + last_seen = excluded.last_seen""", + (ct, package, now, now), + ) + conn.execute( + "INSERT INTO prompt_log (timestamp, class_types) VALUES (?, ?)", + (now, json.dumps(list(class_types))), + ) + conn.commit() + finally: + conn.close() + + def get_node_stats(self): + """Return raw per-node usage data.""" + with self._lock: + conn = self._connect() + try: + conn.row_factory = sqlite3.Row + rows = conn.execute( + "SELECT class_type, package, count, first_seen, last_seen FROM node_usage ORDER BY count DESC" + ).fetchall() + return [dict(r) for r in rows] + finally: + conn.close() + + def get_package_stats(self, mapper): + """Aggregate per-package stats combining DB data with known nodes.""" + node_stats = self.get_node_stats() + + # Build per-package data from DB + packages = {} + for row in node_stats: + pkg = row["package"] + if pkg not in packages: + packages[pkg] = { + "package": pkg, + "total_executions": 0, + "used_nodes": 0, + "nodes": [], + "last_seen": None, + } + entry = packages[pkg] + entry["total_executions"] += row["count"] + entry["used_nodes"] += 1 + entry["nodes"].append(row) + if entry["last_seen"] is None or row["last_seen"] > entry["last_seen"]: + entry["last_seen"] = row["last_seen"] + + # Count total registered nodes per package from mapper + node_counts = {} + for ct, pkg in mapper.mapping.items(): + node_counts.setdefault(pkg, 0) + node_counts[pkg] += 1 + + # Also include zero-node packages from LOADED_MODULE_DIRS + for pkg in mapper.get_all_packages(): + if pkg not in node_counts: + node_counts[pkg] = 0 + + # Merge: ensure every known package appears + for pkg, total in node_counts.items(): + if pkg not in packages: + packages[pkg] = { + "package": pkg, + "total_executions": 0, + "used_nodes": 0, + "nodes": [], + "last_seen": None, + } + packages[pkg]["total_nodes"] = total + packages[pkg]["never_used"] = packages[pkg]["total_executions"] == 0 + + # For packages that came only from DB (e.g. uninstalled), fill total_nodes + for pkg, entry in packages.items(): + if "total_nodes" not in entry: + entry["total_nodes"] = entry["used_nodes"] + entry["never_used"] = False + + result = sorted(packages.values(), key=lambda p: p["total_executions"]) + return result + + def reset(self): + """Clear all tracked data.""" + with self._lock: + conn = self._connect() + try: + conn.execute("DELETE FROM node_usage") + conn.execute("DELETE FROM prompt_log") + conn.commit() + finally: + conn.close()