`;
// Tab switcher — wired via addEventListener after insertion, no onclick globals
html += `
-
+
Nodes
@@ -89,9 +107,9 @@ async function showStatsDialog() {
`;
- // Nodes tab content (existing content, wrapped)
+ // Nodes tab content
html += `
`;
- html += buildNodesTabContent(custom);
+ html += buildNodesTabContent(custom, managerInfo);
html += `
`;
// Models tab content
@@ -135,6 +153,8 @@ async function showStatsDialog() {
});
});
+ wireDisableButtons(dialog, managerInfo);
+
// Easter egg: click "used" badge 5 times to show podium
let eggClicks = 0;
let eggTimer = null;
@@ -155,67 +175,105 @@ async function showStatsDialog() {
}
}
-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");
+// 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 ``;
+}
- let html = `
-
- ${safeToRemove.length}
- safe to remove
-
-
- ${considerRemoving.length}
- consider removing
-
-
- ${unusedNew.length}
- unused <1 month
-
-
- ${used.length}
- used
-
-
`;
+// 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;
+}
- 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");
+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 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;
+function renderSection(title, subtitle, status, packages, managerInfo) {
+ if (packages.length === 0) return "";
- let html = `
-
- ${safeCount}
- safe to remove
-
-
- ${considerCount}
- consider removing
-
-
- ${unusedNewCount}
- unused <1 month
-
-
- ${usedCount}
- used
-
-
`;
+ 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 = `
Disable all (${eligible.length}) `;
+ }
+
+ 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 enabled (active on disk).
+function isDisableEligible(pkg, managerInfo) {
+ if (!managerInfo || !pkg.installed) return false;
+ const info = managerInfo[pkg.package];
+ return !!(info && info.enabled);
+}
+
+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.
`;
@@ -242,22 +300,14 @@ function buildModelTable(models) {
`;
for (const m of models) {
- const { bg, hover } = STATUS_COLORS[m.status] || STATUS_COLORS.used;
+ const meta = STATUS_META[m.status] || STATUS_META.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 += `
${escapeHtml(m.model_name)}
${m.count}
${lastSeen}
- ${statusLabel.text}
+ ${meta.label}
`;
}
@@ -266,22 +316,14 @@ function buildModelTable(models) {
}
function sectionHeader(title, subtitle, color) {
- let html = `${title}`;
- if (subtitle) html += ` — ${subtitle} `;
+ let html = `${escapeHtml(title)}`;
+ if (subtitle) html += ` — ${escapeHtml(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;
+function buildTable(packages, status, withActions, managerInfo) {
+ const colspan = withActions ? 7 : 6;
let html = `
@@ -290,32 +332,36 @@ function buildTable(packages, status) {
Nodes
Used
Executions
- Last Used
- `;
+ 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()
- : "—";
+ const lastSeen = pkg.last_seen ? new Date(pkg.last_seen).toLocaleDateString() : "—";
- html += `
+ html += `
${hasNodes ? "▶" : " "}
${escapeHtml(pkg.package)}
${pkg.total_nodes}
${pkg.used_nodes}/${pkg.total_nodes}
${pkg.total_executions}
- ${lastSeen}
- `;
+ ${lastSeen} `;
+
+ if (withActions) {
+ const eligible = isDisableEligible(pkg, managerInfo);
+ const cell = eligible
+ ? `Disable `
+ : `— `;
+ html += `${cell} `;
+ }
+ html += ``;
if (hasNodes) {
- html += `
+ html += `
`;
for (const node of pkg.nodes) {
- const nLastSeen = node.last_seen
- ? new Date(node.last_seen).toLocaleDateString()
- : "—";
+ const nLastSeen = node.last_seen ? new Date(node.last_seen).toLocaleDateString() : "—";
html += `
${escapeHtml(node.class_type)}
${node.count}
@@ -330,6 +376,221 @@ function buildTable(packages, status) {
return html;
}
+// ---------------------------------------------------------------------------
+// ComfyUI Manager integration: disable unused node packages
+// ---------------------------------------------------------------------------
+
+// Map of installed packages from ComfyUI Manager:
+// { : { ver, cnr_id, aux_id, enabled }, ... }
+// Returns null when the Manager is not installed/reachable, in which case the
+// disable UI is omitted entirely.
+async function fetchManagerInfo() {
+ try {
+ const resp = await fetch("/customnode/installed");
+ if (!resp.ok) return null;
+ const data = await resp.json();
+ return data && typeof data === "object" ? data : null;
+ } catch {
+ return null;
+ }
+}
+
+// Build the payload ComfyUI Manager's /manager/queue/disable expects. CNR
+// (registry) packages are keyed by their cnr_id; everything else is treated as
+// "unknown" and keyed by directory name.
+function disablePayload(dirName, info) {
+ if (info && info.cnr_id && info.ver && info.ver !== "unknown") {
+ return { id: info.cnr_id, version: info.ver, ui_id: dirName };
+ }
+ return { id: dirName, version: "unknown", files: [dirName], ui_id: dirName };
+}
+
+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 enabled (guards against
+ // double-clicks and stale buttons after a partial batch).
+ pkgNames = pkgNames.filter((n) => managerInfo[n] && managerInfo[n].enabled);
+ 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 enabled on disk.
+ const after = await fetchManagerInfo();
+ const isStillEnabled = (n) => after && after[n] && after[n].enabled;
+ const succeeded = after ? pkgNames.filter((n) => !isStillEnabled(n)) : pkgNames;
+ const failed = pkgNames.filter((n) => !succeeded.includes(n));
+
+ succeeded.forEach((n) => { if (managerInfo[n]) managerInfo[n].enabled = false; });
+ 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].enabled);
+ 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.
+
+ Restart ComfyUI
+ Later
+ `;
+
+ 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");
@@ -455,3 +716,13 @@ function escapeHtml(str) {
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, "\\$&");
+}
diff --git a/pyproject.toml b/pyproject.toml
index 2a6fabc..08cb88b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,7 +1,7 @@
[project]
name = "comfyui-nodes-stats"
description = "Track usage statistics for all ComfyUI nodes and packages"
-version = "1.1.0"
+version = "1.2.0"
license = "MIT"
[project.urls]
diff --git a/tests/test_classify.py b/tests/test_classify.py
new file mode 100644
index 0000000..a10a556
--- /dev/null
+++ b/tests/test_classify.py
@@ -0,0 +1,44 @@
+from datetime import datetime, timezone, timedelta
+
+from tracker import _classify_age
+
+
+def _thresholds():
+ now = datetime.now(timezone.utc)
+ return (
+ (now - timedelta(days=30)).isoformat(),
+ (now - timedelta(days=60)).isoformat(),
+ )
+
+
+def _ago(days):
+ return (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
+
+
+def test_recent_used():
+ one, two = _thresholds()
+ assert _classify_age(_ago(5), one, two, "used") == "used"
+
+
+def test_recent_unused_new():
+ one, two = _thresholds()
+ assert _classify_age(_ago(5), one, two, "unused_new") == "unused_new"
+
+
+def test_consider_removing_window():
+ one, two = _thresholds()
+ assert _classify_age(_ago(40), one, two, "used") == "consider_removing"
+ assert _classify_age(_ago(40), one, two, "unused_new") == "consider_removing"
+
+
+def test_safe_to_remove_window():
+ one, two = _thresholds()
+ assert _classify_age(_ago(70), one, two, "used") == "safe_to_remove"
+ assert _classify_age(_ago(70), one, two, "unused_new") == "safe_to_remove"
+
+
+def test_none_timestamp_is_recent():
+ one, two = _thresholds()
+ # No history yet -> treated as recent, never a removal candidate
+ assert _classify_age(None, one, two, "unused_new") == "unused_new"
+ assert _classify_age(None, one, two, "used") == "used"
diff --git a/tracker.py b/tracker.py
index 5d78548..a854a72 100644
--- a/tracker.py
+++ b/tracker.py
@@ -62,6 +62,26 @@ EXCLUDED_PACKAGES = {
}
+def _classify_age(timestamp, one_month_ago, two_months_ago, recent_status):
+ """Classify an ISO timestamp into a removal tier.
+
+ Shared by node-package and model classification so both age the same way.
+
+ timestamp: ISO string of the relevant activity — last_seen for items that
+ have been used, or the tracking start time for never-used items. A
+ None timestamp is treated as recent (not enough history to judge).
+ recent_status: status to return when the timestamp is recent — "used" for
+ items with recorded usage, "unused_new" for never-used items.
+ """
+ if timestamp is None:
+ return recent_status
+ if timestamp < two_months_ago:
+ return "safe_to_remove"
+ if timestamp < one_month_ago:
+ return "consider_removing"
+ return recent_status
+
+
class UsageTracker:
def __init__(self, db_path=DB_PATH):
self._db_path = db_path
@@ -245,22 +265,14 @@ class UsageTracker:
entry["status"] = "uninstalled"
elif entry["total_executions"] > 0:
# Used packages: classify by last_seen recency
- if entry["last_seen"] < two_months_ago:
- entry["status"] = "safe_to_remove"
- elif entry["last_seen"] < one_month_ago:
- entry["status"] = "consider_removing"
- else:
- entry["status"] = "used"
+ entry["status"] = _classify_age(
+ entry["last_seen"], one_month_ago, two_months_ago, "used"
+ )
else:
# Never-used packages: classify by how long we've been tracking
- if tracking_start is None:
- entry["status"] = "unused_new"
- elif tracking_start < two_months_ago:
- entry["status"] = "safe_to_remove"
- elif tracking_start < one_month_ago:
- entry["status"] = "consider_removing"
- else:
- entry["status"] = "unused_new"
+ entry["status"] = _classify_age(
+ tracking_start, one_month_ago, two_months_ago, "unused_new"
+ )
result = [p for p in packages.values() if p["package"].lower() not in EXCLUDED_PACKAGES]
result.sort(key=lambda p: p["total_executions"])
@@ -296,12 +308,9 @@ class UsageTracker:
if model_name in db_models:
row = db_models[model_name]
last_seen = row["last_seen"]
- if last_seen < two_months_ago:
- status = "safe_to_remove"
- elif last_seen < one_month_ago:
- status = "consider_removing"
- else:
- status = "used"
+ status = _classify_age(
+ last_seen, one_month_ago, two_months_ago, "used"
+ )
entry = {
"model_name": model_name,
"model_type": model_type,
@@ -312,14 +321,9 @@ class UsageTracker:
"status": status,
}
else:
- if tracking_start is None:
- status = "unused_new"
- elif tracking_start < two_months_ago:
- status = "safe_to_remove"
- elif tracking_start < one_month_ago:
- status = "consider_removing"
- else:
- status = "unused_new"
+ status = _classify_age(
+ tracking_start, one_month_ago, two_months_ago, "unused_new"
+ )
entry = {
"model_name": model_name,
"model_type": model_type,