diff --git a/README.md b/README.md index 2388c7e..af3592f 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ A ComfyUI custom node package that silently tracks which nodes, packages, and mo - **Smart aging** — items gradually move from "recently unused" to "safe to remove" over time - **Uninstall detection** — removed packages/models are flagged separately, historical data preserved - **Expandable detail** — click any package to see individual node-level stats +- **One-click disable** — disable unused packages straight from the dialog via ComfyUI Manager (per-package or in bulk), reversible at any time - **Non-blocking** — DB writes happen in a background thread, no impact on workflow execution ## Package Classification @@ -63,6 +64,20 @@ Click the **Node Stats** button (bar chart icon) in the ComfyUI top menu bar. A - Summary bar with counts for each classification tier - Sections for each tier, sorted from most actionable to least - Expandable rows — click any package to see per-node execution counts and timestamps +- **Disable** buttons on the "Safe to Remove" and "Consider Removing" tiers (see below) + +### Disabling unused packages + +When [ComfyUI Manager](https://github.com/ltdrdata/ComfyUI-Manager) is installed, the +"Safe to Remove" and "Consider Removing" sections show a **Disable** button on each +package, plus a **Disable all** button per section. Disabling: + +- Hands off to ComfyUI Manager, which moves the package into `custom_nodes/.disabled/` +- Is fully reversible — re-enable any package from ComfyUI Manager whenever you like +- Requires a ComfyUI restart to unload the package from the running session (a banner + with a **Restart ComfyUI** button appears after disabling) + +If ComfyUI Manager is not installed, the disable buttons are hidden and stats work as before. **Models tab** - Summary bar with counts for each tier across all model types diff --git a/js/nodes_stats.js b/js/nodes_stats.js index 7ebd450..9f9d0a7 100644 --- a/js/nodes_stats.js +++ b/js/nodes_stats.js @@ -10,6 +10,20 @@ 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", @@ -34,16 +48,18 @@ app.registerExtension({ }); async function showStatsDialog() { - let data, modelData; + let data, modelData, managerInfo; try { - const [pkgResp, modelResp] = await Promise.all([ + 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; @@ -71,14 +87,16 @@ async function showStatsDialog() { 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 = `
+ let html = dialogStyle(); + + html += `

Usage Stats

`; // Tab switcher — wired via addEventListener after insertion, no onclick globals html += ` -
+
`; - // 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 = ``; + } + + 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) { - - `; + `; + 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 += ` - - `; + `; + + if (withActions) { + const eligible = isDisableEligible(pkg, managerInfo); + const cell = eligible + ? `` + : ``; + html += ``; + } + html += ``; if (hasNodes) { - html += `
Nodes Used ExecutionsLast Used
Last Used
${hasNodes ? "▶" : " "} ${escapeHtml(pkg.package)} ${pkg.total_nodes} ${pkg.used_nodes}/${pkg.total_nodes} ${pkg.total_executions}${lastSeen}
${lastSeen}${cell}