Compare commits

...

4 Commits

Author SHA1 Message Date
Ethanfel 6d433ba371 feat: native disable/enable fallback and package whitelist; bump to 1.8.0
Publish to Comfy registry / Publish Custom Node to registry (push) Waiting to run
Disable/enable no longer require ComfyUI Manager:
- New pack_fs.py moves packs in/out of custom_nodes/.disabled/ (no import,
  delete, or re-clone). Fallback for hand-cloned packs, loose single-file
  nodes, or when Manager is absent. enable strips the @version suffix so
  packs restore as clean, importable dir names.
- Routes: native-disable, native-enable, disabled-packs.
- Frontend routes each disable per-pack (Manager queue vs native move), and
  shows an Enable button on recoverable packs in the Uninstalled tier. The
  restart banner degrades to a manual-restart notice when no Manager exists.

Whitelist (packages-only): a star toggle protects a pack — pulled into its
own pinned group, no Disable button, skipped by the 7-day trial auto-disable.
- New whitelist_packages table; whitelisted flag on package stats.
- Routes: whitelist, whitelist/add, whitelist/remove.

Tests: test_pack_fs.py, test_whitelist.py (60 passing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 17:16:29 +02:00
Ethanfel 7ca7d95ef3 feat(workflow): make auto-open on load opt-in; default off
Publish to Comfy registry / Publish Custom Node to registry (push) Has been cancelled
Loading a workflow with missing/disabled nodes no longer pops the Node
Stats dialog every time. Gated behind a new ComfyUI setting
"Auto-open Node Stats on workflow load" (off by default); the scan still
runs so the Workflow tab is accurate when opened manually. Bump to 1.7.2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 15:11:54 +02:00
Ethanfel 2692bb1752 docs: note built-in manager (--enable-manager-legacy-ui) support; bump to 1.7.1
Publish to Comfy registry / Publish Custom Node to registry (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 13:24:30 +02:00
Ethanfel e043ec865c fix(manager): support ComfyUI core's bundled manager (v4 /v2 API)
ComfyUI core now bundles comfyui_manager v4 (enabled via --enable-manager),
which serves all Manager HTTP routes under a /v2 prefix and replaces the
per-item /manager/queue/install and /manager/queue/disable endpoints with a
single /v2/manager/queue/batch call. The extension called the old unprefixed
paths, so every request 404'd, fetchManagerInfo() returned null, and all
disable/enable/install/trial features went inert ("can't find manager").

Add detectManagerApi() to probe once (cheap queue/status) and cache which
generation answers: v3 (old git Manager, unprefixed, per-item) or v4-legacy
(/v2 + batch). Route every read through a prefix-aware mgrFetch(), and branch
install/disable on the batch transport via a shared runManagerBatch() helper.
Payload shapes are unchanged — v4's _install_custom_node/_disable_node read the
same fields. Verified against a live v4-legacy server on :8189.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 13:23:35 +02:00
8 changed files with 899 additions and 70 deletions
+9 -1
View File
@@ -15,7 +15,8 @@ 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 - **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 - **Uninstall detection** — removed packages/models are flagged separately, historical data preserved
- **Expandable detail** — click any package to see individual node-level stats - **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 - **One-click disable** — disable unused packages straight from the dialog (per-package or in bulk), reversible at any time. Uses ComfyUI Manager when it manages the pack, and **falls back to a native disable** (moving the pack into `custom_nodes/.disabled/`) for hand-cloned packs, loose single-file nodes, or when Manager isn't installed. Natively-disabled packs get an **Enable** button in the *Uninstalled* tier to move them back — no Manager needed (restart to apply)
- **Whitelist** — click the ☆ star on any package to protect it: whitelisted packs move into their own pinned group, never show a Disable button, and are skipped by the 7-day trial auto-disable
- **Workflow tab** — on loading a workflow, splits unresolved nodes into *Missing* (install permanently or on a trial) and *Disabled* (enable permanently or on a trial), with a rolling **7-day trial** that auto-disables packages left unused - **Workflow tab** — on loading a workflow, splits unresolved nodes into *Missing* (install permanently or on a trial) and *Disabled* (enable permanently or on a trial), with a rolling **7-day trial** that auto-disables packages left unused
- **Mirror search** — a standalone palette (⌕ button / `Ctrl/Cmd+Shift+D`) that searches nodes belonging to currently-disabled packages, draws an imitation node box (real inputs/widgets/outputs, parsed from source), and re-enables the pack on the spot - **Mirror search** — a standalone palette (⌕ button / `Ctrl/Cmd+Shift+D`) that searches nodes belonging to currently-disabled packages, draws an imitation node box (real inputs/widgets/outputs, parsed from source), and re-enables the pack on the spot
- **Non-blocking** — DB writes happen in a background thread, no impact on workflow execution - **Non-blocking** — DB writes happen in a background thread, no impact on workflow execution
@@ -81,6 +82,13 @@ package, plus a **Disable all** button per section. Disabling:
If ComfyUI Manager is not installed, the disable buttons are hidden and stats work as before. If ComfyUI Manager is not installed, the disable buttons are hidden and stats work as before.
> **Manager compatibility:** works with both the standalone
> [ComfyUI-Manager](https://github.com/ltdrdata/ComfyUI-Manager) custom node and
> ComfyUI core's built-in manager. For the built-in manager, launch ComfyUI with
> `--enable-manager-legacy-ui` (the extension detects the active manager and its
> API automatically). Without a reachable manager, all enable/disable/install
> actions are simply omitted.
### Workflow tab & temporary enable ### Workflow tab & temporary enable
Whenever you load a workflow, the extension scans for node types the running Whenever you load a workflow, the extension scans for node types the running
+95
View File
@@ -7,6 +7,7 @@ from server import PromptServer
from .mapper import NodePackageMapper, ModelMapper from .mapper import NodePackageMapper, ModelMapper
from .node_introspect import find_disabled_pack_path, get_node_schema from .node_introspect import find_disabled_pack_path, get_node_schema
from .pack_fs import disable_pack_native, enable_pack_native, list_disabled_packs
from .tracker import UsageTracker from .tracker import UsageTracker
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -144,6 +145,100 @@ async def get_node_schema_route(request):
return web.json_response({"error": "internal error"}, status=500) return web.json_response({"error": "internal error"}, status=500)
@routes.post("/nodes-stats/native-disable")
async def native_disable(request):
"""Disable a pack by moving it into custom_nodes/.disabled/ (no Manager).
Fallback for packs ComfyUI Manager doesn't manage. A restart is required for
ComfyUI to unload the pack.
"""
try:
data = await request.json()
package = data.get("package")
if not package:
return web.json_response({"error": "package required"}, status=400)
ok, message = await asyncio.get_event_loop().run_in_executor(
None, disable_pack_native, package
)
if not ok:
return web.json_response({"status": "error", "message": message}, status=409)
mapper.invalidate()
return web.json_response({"status": "ok", "message": message})
except Exception:
logger.error("nodes-stats: error in native disable", exc_info=True)
return web.json_response({"error": "internal error"}, status=500)
@routes.post("/nodes-stats/native-enable")
async def native_enable(request):
"""Re-enable a pack by moving it out of custom_nodes/.disabled/ (no Manager)."""
try:
data = await request.json()
package = data.get("package")
if not package:
return web.json_response({"error": "package required"}, status=400)
ok, message = await asyncio.get_event_loop().run_in_executor(
None, enable_pack_native, package
)
if not ok:
return web.json_response({"status": "error", "message": message}, status=409)
mapper.invalidate()
return web.json_response({"status": "ok", "message": message})
except Exception:
logger.error("nodes-stats: error in native enable", exc_info=True)
return web.json_response({"error": "internal error"}, status=500)
@routes.get("/nodes-stats/disabled-packs")
async def get_disabled_packs(request):
"""List packs present in custom_nodes/.disabled/ (re-enable candidates)."""
try:
names = await asyncio.get_event_loop().run_in_executor(None, list_disabled_packs)
return web.json_response(sorted(names))
except Exception:
logger.error("nodes-stats: error listing disabled packs", exc_info=True)
return web.json_response({"error": "internal error"}, status=500)
@routes.get("/nodes-stats/whitelist")
async def get_whitelist(request):
try:
return web.json_response(sorted(tracker.get_whitelist()))
except Exception:
logger.error("nodes-stats: error getting whitelist", exc_info=True)
return web.json_response({"error": "internal error"}, status=500)
@routes.post("/nodes-stats/whitelist/add")
async def whitelist_add(request):
try:
data = await request.json()
package = data.get("package")
if not package:
return web.json_response({"error": "package required"}, status=400)
tracker.add_to_whitelist(package)
return web.json_response({"status": "ok"})
except Exception:
logger.error("nodes-stats: error adding to whitelist", exc_info=True)
return web.json_response({"error": "internal error"}, status=500)
@routes.post("/nodes-stats/whitelist/remove")
async def whitelist_remove(request):
try:
data = await request.json()
package = data.get("package")
if not package:
return web.json_response({"error": "package required"}, status=400)
tracker.remove_from_whitelist(package)
return web.json_response({"status": "ok"})
except Exception:
logger.error("nodes-stats: error removing from whitelist", exc_info=True)
return web.json_response({"error": "internal error"}, status=500)
@routes.get("/nodes-stats/trials") @routes.get("/nodes-stats/trials")
async def get_trials(request): async def get_trials(request):
try: try:
+383 -68
View File
@@ -24,9 +24,35 @@ const STATUS_META = {
// Tiers that may offer a "Disable" action (when ComfyUI Manager is available). // Tiers that may offer a "Disable" action (when ComfyUI Manager is available).
const DISABLEABLE_TIERS = new Set(["safe_to_remove", "consider_removing"]); const DISABLEABLE_TIERS = new Set(["safe_to_remove", "consider_removing"]);
// Setting id for the auto-open-on-load behavior. Defaults to off so loading a
// workflow with missing/disabled nodes no longer pops the dialog every time.
const SETTING_AUTO_OPEN = "comfyui.nodes_stats.autoOpenOnLoad";
function autoOpenEnabled() {
try {
const v = app.extensionManager?.setting?.get(SETTING_AUTO_OPEN);
if (v !== undefined) return !!v;
return !!app.ui?.settings?.getSettingValue?.(SETTING_AUTO_OPEN, false);
} catch {
return false;
}
}
app.registerExtension({ app.registerExtension({
name: "comfyui.nodes_stats", name: "comfyui.nodes_stats",
settings: [
{
id: SETTING_AUTO_OPEN,
name: "Auto-open Node Stats on workflow load",
tooltip:
"Pop the Node Stats dialog (Workflow tab) automatically when a loaded workflow has missing or disabled nodes. Off by default — open it manually from the toolbar instead.",
type: "boolean",
defaultValue: false,
category: ["Node Stats", "Behavior", "Auto-open on load"],
},
],
async setup() { async setup() {
const btn = document.createElement("button"); const btn = document.createElement("button");
btn.innerHTML = STATS_ICON; btn.innerHTML = STATS_ICON;
@@ -93,9 +119,16 @@ function unresolvedNodeTypes() {
// Latest workflow scan, shared so showStatsDialog can render the Workflow tab. // Latest workflow scan, shared so showStatsDialog can render the Workflow tab.
let _lastWorkflowScan = { disabled: [], missing: [] }; let _lastWorkflowScan = { disabled: [], missing: [] };
// Lowercased names of packs currently in custom_nodes/.disabled/ — the packs the
// extension can natively re-enable. Refreshed each time the dialog opens.
let _disabledPacksSet = new Set();
async function onWorkflowLoaded() { async function onWorkflowLoaded() {
const types = unresolvedNodeTypes(); const types = unresolvedNodeTypes();
_lastWorkflowScan = await classifyUnresolved(types); _lastWorkflowScan = await classifyUnresolved(types);
// Only auto-open when the user has opted in; the scan is still kept up to date
// so the Workflow tab is accurate when opened manually from the toolbar.
if (!autoOpenEnabled()) return;
if (_lastWorkflowScan.disabled.length || _lastWorkflowScan.missing.length) { if (_lastWorkflowScan.disabled.length || _lastWorkflowScan.missing.length) {
showStatsDialog("workflow"); // auto-open on the Workflow tab showStatsDialog("workflow"); // auto-open on the Workflow tab
} }
@@ -104,12 +137,14 @@ async function onWorkflowLoaded() {
async function showStatsDialog(initialTab = "nodes") { async function showStatsDialog(initialTab = "nodes") {
let data, modelData, managerInfo, trials = []; let data, modelData, managerInfo, trials = [];
try { try {
const [pkgResp, modelResp, mgr, trialsResp] = await Promise.all([ const [pkgResp, modelResp, mgr, trialsResp, disabledPacks] = await Promise.all([
fetch("/nodes-stats/packages"), fetch("/nodes-stats/packages"),
fetch("/nodes-stats/models"), fetch("/nodes-stats/models"),
fetchManagerInfo(), fetchManagerInfo(),
fetch("/nodes-stats/trials").catch(() => null), fetch("/nodes-stats/trials").catch(() => null),
fetchDisabledPacks(),
]); ]);
_disabledPacksSet = disabledPacks;
if (!pkgResp.ok) { alert("Failed to load node stats: HTTP " + pkgResp.status); return; } 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; } if (!modelResp.ok) { alert("Failed to load model stats: HTTP " + modelResp.status); return; }
data = await pkgResp.json(); data = await pkgResp.json();
@@ -219,6 +254,8 @@ async function showStatsDialog(initialTab = "nodes") {
wireDisableButtons(dialog, managerInfo); wireDisableButtons(dialog, managerInfo);
wireWorkflowButtons(dialog); wireWorkflowButtons(dialog);
wireWhitelistButtons(dialog);
wireNativeEnableButtons(dialog);
switchTab(TABS.includes(initialTab) ? initialTab : "nodes"); switchTab(TABS.includes(initialTab) ? initialTab : "nodes");
@@ -256,6 +293,7 @@ function dialogStyle() {
#nodes-stats-dialog .ns-btn:hover:not(:disabled){background:#3a2020;border-color:#e44;color:#fff;} #nodes-stats-dialog .ns-btn:hover:not(:disabled){background:#3a2020;border-color:#e44;color:#fff;}
#nodes-stats-dialog .ns-btn:disabled{opacity:0.5;cursor:default;} #nodes-stats-dialog .ns-btn:disabled{opacity:0.5;cursor:default;}
#nodes-stats-dialog .ns-disable-all-btn{border-color:#a33;color:#e88;} #nodes-stats-dialog .ns-disable-all-btn{border-color:#a33;color:#e88;}
#nodes-stats-dialog .ns-enable-native-btn:hover:not(:disabled){background:#203a20;border-color:#4a4;color:#fff;}
${rows} ${rows}
</style>`; </style>`;
} }
@@ -277,7 +315,11 @@ function summaryBar(items) {
} }
function buildNodesTabContent(custom, managerInfo) { function buildNodesTabContent(custom, managerInfo) {
const byStatus = (s) => custom.filter((p) => p.status === s); // Whitelisted packs are pulled out of their usage tiers into a pinned group
// and never offered for disable. Everything else classifies as usual.
const whitelisted = custom.filter((p) => p.whitelisted);
const rest = custom.filter((p) => !p.whitelisted);
const byStatus = (s) => rest.filter((p) => p.status === s);
const safeToRemove = byStatus("safe_to_remove"); const safeToRemove = byStatus("safe_to_remove");
const considerRemoving = byStatus("consider_removing"); const considerRemoving = byStatus("consider_removing");
const unusedNew = byStatus("unused_new"); const unusedNew = byStatus("unused_new");
@@ -291,6 +333,7 @@ function buildNodesTabContent(custom, managerInfo) {
{ count: used.length, status: "used", label: "used", id: "nodes-stats-used-badge" }, { count: used.length, status: "used", label: "used", id: "nodes-stats-used-badge" },
]); ]);
html += renderWhitelistSection(whitelisted, managerInfo);
html += renderSection("Safe to Remove", "Unused for 2+ months", "safe_to_remove", safeToRemove, managerInfo); 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("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("Recently Unused", "Unused for less than 1 month", "unused_new", unusedNew, managerInfo);
@@ -304,8 +347,12 @@ function renderSection(title, subtitle, status, packages, managerInfo) {
if (packages.length === 0) return ""; if (packages.length === 0) return "";
const color = STATUS_META[status].color; const color = STATUS_META[status].color;
const withActions = !!managerInfo && DISABLEABLE_TIERS.has(status); // Disable actions no longer require Manager: an installed pack Manager doesn't
const eligible = withActions // cover is disabled natively (moved into custom_nodes/.disabled by us).
// The Uninstalled tier gets an Enable action for packs recoverable from
// .disabled/ (also native, so it works with or without Manager).
const withActions = DISABLEABLE_TIERS.has(status) || sectionHasEnable(status, packages);
const eligible = DISABLEABLE_TIERS.has(status)
? packages.filter((p) => isDisableEligible(p, managerInfo)).map((p) => p.package) ? packages.filter((p) => isDisableEligible(p, managerInfo)).map((p) => p.package)
: []; : [];
@@ -323,12 +370,48 @@ function renderSection(title, subtitle, status, packages, managerInfo) {
return html; return html;
} }
// A package can be disabled only if ComfyUI Manager knows it (by directory // How a package can be disabled: "manager" when ComfyUI Manager knows it (by
// name) and it is currently active (any state other than already-disabled). // directory name) and it's currently active; "native" when it's installed but
// Manager doesn't cover it (hand-cloned repo, loose file, or Manager absent) —
// the extension moves it into custom_nodes/.disabled itself; null when it can't
// be disabled (uninstalled, or whitelisted/protected).
function disableMode(pkg, managerInfo) {
if (pkg.whitelisted || !pkg.installed) return null;
const info = managerInfo && managerInfo[pkg.package];
if (info) {
// Manager knows this pack: offer disable only while it's still active.
return info.state && info.state !== "disabled" ? "manager" : null;
}
// Manager doesn't cover it (hand-cloned, loose file, or Manager absent):
// the extension moves it into custom_nodes/.disabled itself.
return "native";
}
function isDisableEligible(pkg, managerInfo) { function isDisableEligible(pkg, managerInfo) {
if (!managerInfo || !pkg.installed) return false; return disableMode(pkg, managerInfo) !== null;
const info = managerInfo[pkg.package]; }
return !!(info && info.state && info.state !== "disabled");
// An uninstalled pack can be natively re-enabled iff its source is still sitting
// in custom_nodes/.disabled/. (Whitelisted packs live in their own group.)
function nativeEnableEligible(pkg) {
return pkg.status === "uninstalled" && _disabledPacksSet.has(pkg.package.toLowerCase());
}
function sectionHasEnable(status, packages) {
return status === "uninstalled" && packages.some(nativeEnableEligible);
}
const WHITELIST_COLOR = "#e5c04b";
// Pinned group of protected packs. They keep their own row background (mixed
// statuses) but never show a Disable action — the star toggles protection off.
function renderWhitelistSection(packages, managerInfo) {
if (packages.length === 0) return "";
let html = `<div style="display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin:16px 0 8px;">
<h3 style="color:${WHITELIST_COLOR};margin:0;font-size:14px;">★ Whitelist <span style="color:#666;font-size:12px;font-weight:normal;">— protected, never disabled</span></h3>
</div>`;
html += buildTable(packages, null, false, managerInfo);
return html;
} }
function buildModelsTabContent(modelData) { function buildModelsTabContent(modelData) {
@@ -433,6 +516,15 @@ function sectionHeader(title, subtitle, color) {
return html; return html;
} }
// Clickable whitelist toggle rendered before a package name. Filled gold star
// when protected, hollow grey when not. Wired by wireWhitelistButtons.
function whitelistStar(pkg) {
const on = !!pkg.whitelisted;
return `<span class="ns-wl-btn" data-pkg="${escapeAttr(pkg.package)}" data-on="${on ? 1 : 0}" ` +
`title="${on ? "Remove from whitelist" : "Add to whitelist (protect from disable)"}" ` +
`style="cursor:pointer;margin-right:7px;color:${on ? WHITELIST_COLOR : "#555"};">${on ? "★" : "☆"}</span>`;
}
function buildTable(packages, status, withActions, managerInfo) { function buildTable(packages, status, withActions, managerInfo) {
const colspan = withActions ? 7 : 6; const colspan = withActions ? 7 : 6;
@@ -451,19 +543,23 @@ function buildTable(packages, status, withActions, managerInfo) {
const hasNodes = pkg.nodes && pkg.nodes.length > 0; 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 += `<tr class="pkg-row ns-row-${status}" style="cursor:${hasNodes ? "pointer" : "default"};border-bottom:1px solid #222;"> html += `<tr class="pkg-row ns-row-${pkg.status}" style="cursor:${hasNodes ? "pointer" : "default"};border-bottom:1px solid #222;">
<td style="padding:6px 8px;width:20px;"><span class="arrow" style="color:#666;">${hasNodes ? "▶" : " "}</span></td> <td style="padding:6px 8px;width:20px;"><span class="arrow" style="color:#666;">${hasNodes ? "▶" : " "}</span></td>
<td style="padding:6px 8px;color:#fff;">${escapeHtml(pkg.package)}</td> <td style="padding:6px 8px;color:#fff;">${whitelistStar(pkg)}${escapeHtml(pkg.package)}</td>
<td style="padding:6px 8px;text-align:right;">${pkg.total_nodes}</td> <td style="padding:6px 8px;text-align:right;">${pkg.total_nodes}</td>
<td style="padding:6px 8px;text-align:right;">${pkg.used_nodes}/${pkg.total_nodes}</td> <td style="padding:6px 8px;text-align:right;">${pkg.used_nodes}/${pkg.total_nodes}</td>
<td style="padding:6px 8px;text-align:right;">${pkg.total_executions}</td> <td style="padding:6px 8px;text-align:right;">${pkg.total_executions}</td>
<td style="padding:6px 8px;color:#888;">${lastSeen}</td>`; <td style="padding:6px 8px;color:#888;">${lastSeen}</td>`;
if (withActions) { if (withActions) {
const eligible = isDisableEligible(pkg, managerInfo); let cell;
const cell = eligible if (nativeEnableEligible(pkg)) {
? `<button class="ns-btn ns-disable-btn" data-pkg="${escapeAttr(pkg.package)}">Disable</button>` cell = `<button class="ns-btn ns-enable-native-btn" data-pkg="${escapeAttr(pkg.package)}" style="border-color:#3a6;color:#8d8;">Enable</button>`;
: `<span style="color:#555;">—</span>`; } else if (isDisableEligible(pkg, managerInfo)) {
cell = `<button class="ns-btn ns-disable-btn" data-pkg="${escapeAttr(pkg.package)}">Disable</button>`;
} else {
cell = `<span style="color:#555;">—</span>`;
}
html += `<td class="ns-action-cell" data-pkg="${escapeAttr(pkg.package)}" style="padding:6px 8px;text-align:right;">${cell}</td>`; html += `<td class="ns-action-cell" data-pkg="${escapeAttr(pkg.package)}" style="padding:6px 8px;text-align:right;">${cell}</td>`;
} }
html += `</tr>`; html += `</tr>`;
@@ -491,6 +587,43 @@ function buildTable(packages, status, withActions, managerInfo) {
// ComfyUI Manager integration: disable unused node packages // ComfyUI Manager integration: disable unused node packages
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// ComfyUI Manager ships in two HTTP API generations we support:
// • v3 — the standalone git custom_nodes/ComfyUI-Manager. Unprefixed paths
// (/customnode/..., /manager/...) and per-item queue endpoints
// (/manager/queue/install, /manager/queue/disable).
// • v4-legacy — the `comfyui_manager` package bundled with ComfyUI core, run
// with `--enable-manager-legacy-ui`. Same routes under a /v2 prefix, but the
// per-item install/disable endpoints are gone: everything is queued through
// one /v2/manager/queue/batch call ({install:[...], disable:[...]}) that
// also starts the worker.
// We probe once (cheap queue/status, present in both) and cache which generation
// answers, so every later call targets the right prefix + transport. null means
// no Manager is reachable — the disable/enable/install UI is then omitted.
let _managerApiProbe = null;
function detectManagerApi() {
if (!_managerApiProbe) {
_managerApiProbe = (async () => {
for (const prefix of ["/v2", ""]) { // prefer v4-legacy; fall back to v3
try {
const r = await fetch(`${prefix}/manager/queue/status`);
if (r.ok) return { prefix, batch: prefix === "/v2" };
} catch { /* try next generation */ }
}
return null;
})();
}
return _managerApiProbe;
}
// Fetch a Manager endpoint on whichever generation is active, prefixing the path
// as needed. Returns the Response, or null when no Manager is reachable (callers
// treat null exactly like a failed/absent Manager).
async function mgrFetch(path, opts) {
const api = await detectManagerApi();
if (!api) return null;
return fetch(api.prefix + path, opts);
}
// Map of installed packages from ComfyUI Manager, keyed by directory name: // Map of installed packages from ComfyUI Manager, keyed by directory name:
// { <dir name>: { id, version, files, state }, ... } // { <dir name>: { id, version, files, state }, ... }
// We read the unified list (/customnode/getlist) rather than /customnode/installed // We read the unified list (/customnode/getlist) rather than /customnode/installed
@@ -502,8 +635,8 @@ function buildTable(packages, status, withActions, managerInfo) {
// omitted entirely. // omitted entirely.
async function fetchManagerInfo() { async function fetchManagerInfo() {
try { try {
const resp = await fetch("/customnode/getlist?mode=local&skip_update=true"); const resp = await mgrFetch("/customnode/getlist?mode=local&skip_update=true");
if (!resp.ok) return null; if (!resp || !resp.ok) return null;
const data = await resp.json(); const data = await resp.json();
const packs = data && data.node_packs; const packs = data && data.node_packs;
if (!packs || typeof packs !== "object") return null; if (!packs || typeof packs !== "object") return null;
@@ -589,8 +722,8 @@ async function ensureDisabledCatalog(forceRefresh = false) {
if (!managerInfo) return null; // Manager absent if (!managerInfo) return null; // Manager absent
let mappings = {}; let mappings = {};
try { try {
const r = await fetch("/customnode/getmappings?mode=local"); const r = await mgrFetch("/customnode/getmappings?mode=local");
if (r.ok) mappings = await r.json(); if (r && r.ok) mappings = await r.json();
} catch { /* fall through -> empty catalog */ } } catch { /* fall through -> empty catalog */ }
_disabledCatalog = buildDisabledCatalog(mappings, managerInfo); _disabledCatalog = buildDisabledCatalog(mappings, managerInfo);
return _disabledCatalog; return _disabledCatalog;
@@ -634,10 +767,10 @@ async function classifyUnresolved(types) {
let mappings = {}, managerInfo = null; let mappings = {}, managerInfo = null;
try { try {
const [mResp, gi] = await Promise.all([ const [mResp, gi] = await Promise.all([
fetch("/customnode/getmappings?mode=local"), mgrFetch("/customnode/getmappings?mode=local"),
fetchManagerInfo(), // getlist -> {dir: {id, cnr_id, aux_id, version, files, state}} fetchManagerInfo(), // getlist -> {dir: {id, cnr_id, aux_id, version, files, state}}
]); ]);
if (mResp.ok) mappings = await mResp.json(); if (mResp && mResp.ok) mappings = await mResp.json();
managerInfo = gi; managerInfo = gi;
} catch { /* manager absent */ } } catch { /* manager absent */ }
@@ -675,8 +808,8 @@ async function classifyUnresolved(types) {
async function resolveInstallTarget(packKey) { async function resolveInstallTarget(packKey) {
let packs; let packs;
try { try {
const r = await fetch("/customnode/getlist?mode=local&skip_update=true"); const r = await mgrFetch("/customnode/getlist?mode=local&skip_update=true");
if (!r.ok) return null; if (!r || !r.ok) return null;
packs = (await r.json()).node_packs; packs = (await r.json()).node_packs;
} catch { return null; } } catch { return null; }
if (!packs) return null; if (!packs) return null;
@@ -706,8 +839,8 @@ function installPayload(entry, packKey) {
async function findInstalledDir(entry) { async function findInstalledDir(entry) {
let packs = null; let packs = null;
try { try {
const r = await fetch("/customnode/getlist?mode=local&skip_update=true"); const r = await mgrFetch("/customnode/getlist?mode=local&skip_update=true");
if (r.ok) packs = (await r.json()).node_packs; if (r && r.ok) packs = (await r.json()).node_packs;
} catch { /* fall through to basename */ } } catch { /* fall through to basename */ }
if (packs) { if (packs) {
const want = [entry.id, entry.repository, ...(entry.files || [])].filter(Boolean).map(normKey); const want = [entry.id, entry.repository, ...(entry.files || [])].filter(Boolean).map(normKey);
@@ -733,8 +866,8 @@ function disablePayload(dirName, info) {
} }
function wireDisableButtons(dialog, managerInfo) { function wireDisableButtons(dialog, managerInfo) {
if (!managerInfo) return; // No early return on a missing Manager: native-disable buttons are rendered
// for installed packs Manager doesn't cover, and must work with no Manager.
dialog.querySelectorAll(".ns-disable-btn").forEach((btn) => { dialog.querySelectorAll(".ns-disable-btn").forEach((btn) => {
btn.addEventListener("click", (e) => { btn.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
@@ -765,48 +898,156 @@ function wireWorkflowButtons(dialog) {
b.addEventListener("click", (e) => { e.stopPropagation(); handleTrialInstall(b.dataset.pkg, dialog, false); })); b.addEventListener("click", (e) => { e.stopPropagation(); handleTrialInstall(b.dataset.pkg, dialog, false); }));
} }
async function handleDisable(pkgNames, dialog, managerInfo) { // Star toggles on package rows: add/remove the pack from the whitelist, then
// Only act on packages Manager still reports as active (guards against // re-render the Nodes tab so the pack moves in/out of the pinned group.
// double-clicks and stale buttons after a partial batch). function wireWhitelistButtons(dialog) {
pkgNames = pkgNames.filter((n) => managerInfo[n] && managerInfo[n].state !== "disabled"); dialog.querySelectorAll(".ns-wl-btn").forEach((btn) => {
if (pkgNames.length === 0) return; btn.addEventListener("click", (e) => {
e.stopPropagation();
toggleWhitelist(btn.dataset.pkg, btn.dataset.on !== "1");
});
});
}
const what = pkgNames.length === 1 ? `"${pkgNames[0]}"` : `${pkgNames.length} packages`; async function toggleWhitelist(pkg, add) {
try {
const r = await fetch(add ? "/nodes-stats/whitelist/add" : "/nodes-stats/whitelist/remove", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ package: pkg }),
});
if (!r.ok) throw new Error("HTTP " + r.status);
} catch (e) {
notify("Whitelist update failed: " + e.message, "error");
return;
}
showStatsDialog("nodes"); // re-fetch + rebuild so the pack regroups
}
async function fetchWhitelist() {
try {
const r = await fetch("/nodes-stats/whitelist");
if (r.ok) return new Set(await r.json());
} catch { /* treat as empty */ }
return new Set();
}
// Enable buttons on recoverable uninstalled packs: move the pack out of
// custom_nodes/.disabled via the native route. Restart is left to the user.
function wireNativeEnableButtons(dialog) {
dialog.querySelectorAll(".ns-enable-native-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
handleNativeEnable(btn.dataset.pkg, btn, dialog);
});
});
}
async function handleNativeEnable(pkg, btn, dialog) {
if (!confirm(
`Enable "${pkg}"?\n\nIt will be moved out of custom_nodes/.disabled and ` +
`load on the next ComfyUI restart.`
)) return;
btn.disabled = true;
try {
const r = await fetch("/nodes-stats/native-enable", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ package: pkg }),
});
if (!r.ok) {
let msg = "HTTP " + r.status;
try { const j = await r.json(); if (j && j.message) msg = j.message; } catch { /* keep status */ }
throw new Error(msg);
}
_disabledPacksSet.delete(pkg.toLowerCase());
const cell = dialog.querySelector(`.ns-action-cell[data-pkg="${cssEscape(pkg)}"]`);
if (cell) cell.innerHTML = `<span style="color:#6a6;font-size:11px;">✓ enabled · restart</span>`;
showRestartBanner(dialog);
notify(`Enabled ${pkg}. Restart ComfyUI to load it.`, "success");
} catch (e) {
btn.disabled = false;
notify("Failed to enable: " + e.message, "error");
}
}
// Lowercased set of pack names in custom_nodes/.disabled/ (native re-enable
// candidates). Lowercased so lookups match package names case-insensitively.
async function fetchDisabledPacks() {
try {
const r = await fetch("/nodes-stats/disabled-packs");
if (r.ok) return new Set((await r.json()).map((n) => n.toLowerCase()));
} catch { /* treat as empty */ }
return new Set();
}
async function handleDisable(pkgNames, dialog, managerInfo) {
// Partition into packages Manager can disable (still reported active) vs. the
// rest, which we disable natively by moving them into custom_nodes/.disabled.
// The eligible lists already excluded whitelisted/uninstalled packs.
const managerNames = pkgNames.filter(
(n) => managerInfo && managerInfo[n] && managerInfo[n].state && managerInfo[n].state !== "disabled"
);
const nativeNames = pkgNames.filter((n) => !managerNames.includes(n));
if (managerNames.length === 0 && nativeNames.length === 0) return;
const total = managerNames.length + nativeNames.length;
const what = total === 1 ? `"${managerNames[0] || nativeNames[0]}"` : `${total} packages`;
const via = managerNames.length && nativeNames.length
? "ComfyUI Manager / directly"
: managerNames.length ? "ComfyUI Manager" : "the extension";
const confirmMsg = const confirmMsg =
`Disable ${what} via ComfyUI Manager?\n\n` + `Disable ${what} via ${via}?\n\n` +
`They will be moved to custom_nodes/.disabled and a ComfyUI restart is ` + `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.`; `required to take effect. You can re-enable them anytime.`;
if (!confirm(confirmMsg)) return; if (!confirm(confirmMsg)) return;
setDisableButtonsBusy(dialog, true); setDisableButtonsBusy(dialog, true);
const succeeded = [];
const failed = [];
try { try {
const pre = await fetch("/manager/queue/status").then((r) => (r.ok ? r.json() : null)).catch(() => null); if (managerNames.length) {
if (pre && pre.is_processing) { const pre = await mgrFetch("/manager/queue/status").then((r) => (r && r.ok ? r.json() : null)).catch(() => null);
notify("ComfyUI Manager is busy. Please try again in a moment.", "warn"); if (pre && pre.is_processing) {
setDisableButtonsBusy(dialog, false); notify("ComfyUI Manager is busy. Please try again in a moment.", "warn");
return; failed.push(...managerNames);
} else {
try {
await runManagerDisable(managerNames.map((n) => disablePayload(n, managerInfo[n])));
// Reconcile against Manager's actual state: disabled only if no longer
// reported as active on disk.
const after = await fetchManagerInfo();
const isStillActive = (n) => after && after[n] && after[n].state !== "disabled";
for (const n of managerNames) {
if (after ? !isStillActive(n) : true) {
succeeded.push(n);
if (managerInfo[n]) managerInfo[n].state = "disabled";
} else {
failed.push(n);
}
}
} catch (e) {
failed.push(...managerNames);
notify("ComfyUI Manager disable failed: " + e.message, "error");
}
}
} }
const payloads = pkgNames.map((n) => disablePayload(n, managerInfo[n])); if (nativeNames.length) {
await runManagerDisable(payloads); const res = await runNativeDisable(nativeNames);
succeeded.push(...res.succeeded);
failed.push(...res.failed);
}
// 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); markPackagesDisabled(dialog, succeeded);
updateBulkButtons(dialog, managerInfo); updateBulkButtons(dialog, new Set(succeeded));
if (succeeded.length > 0) { if (succeeded.length > 0) {
showRestartBanner(dialog); showRestartBanner(dialog);
notify(`Disabled ${succeeded.length} package${succeeded.length !== 1 ? "s" : ""}. Restart ComfyUI to apply.`, "success"); notify(`Disabled ${succeeded.length} package${succeeded.length !== 1 ? "s" : ""}. Restart ComfyUI to apply.`, "success");
} }
if (failed.length > 0) { if (failed.length > 0) {
notify(`ComfyUI Manager could not disable: ${failed.join(", ")}`, "error"); notify(`Could not disable: ${failed.join(", ")}`, "error");
} }
} catch (e) { } catch (e) {
notify("Failed to disable: " + e.message, "error"); notify("Failed to disable: " + e.message, "error");
@@ -815,9 +1056,52 @@ async function handleDisable(pkgNames, dialog, managerInfo) {
} }
} }
// Disable packs by moving each into custom_nodes/.disabled via the extension's
// own route (no Manager). Returns { succeeded, failed } name lists.
async function runNativeDisable(pkgNames) {
const succeeded = [];
const failed = [];
for (const n of pkgNames) {
try {
const r = await fetch("/nodes-stats/native-disable", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ package: n }),
});
(r.ok ? succeeded : failed).push(n);
} catch {
failed.push(n);
}
}
return { succeeded, failed };
}
// v4-legacy: queue a batch of operations ({install:[...]}, {disable:[...]}, ...)
// through the single endpoint that also starts the worker, then wait for it to
// finish. A reset first clears any stale queue (harmless if empty).
async function runManagerBatch(api, body) {
await fetch(api.prefix + "/manager/queue/reset", { method: "POST", headers: { "Content-Type": "application/json" } });
const r = await fetch(api.prefix + "/manager/queue/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!r.ok) throw new Error(`queue batch failed (HTTP ${r.status})`);
await waitForQueue();
}
// Queue the disable tasks and run them, then wait for the Manager worker to // 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. // finish. v4-legacy batches every item through one /v2/manager/queue/batch call;
// v3 posts each item to /manager/queue/disable then /manager/queue/start (which
// returns 201 if a worker is already running).
async function runManagerDisable(payloads) { async function runManagerDisable(payloads) {
const api = await detectManagerApi();
if (!api) throw new Error("ComfyUI Manager not available");
if (api.batch) {
await runManagerBatch(api, { disable: payloads });
return;
}
await fetch("/manager/queue/reset", { method: "POST", headers: { "Content-Type": "application/json" } }); await fetch("/manager/queue/reset", { method: "POST", headers: { "Content-Type": "application/json" } });
for (const payload of payloads) { for (const payload of payloads) {
@@ -864,8 +1148,8 @@ function enablePayload(dirName, info) {
// the same way before calling runManagerDisable). // the same way before calling runManagerDisable).
async function managerIsBusy() { async function managerIsBusy() {
try { try {
const r = await fetch("/manager/queue/status"); const r = await mgrFetch("/manager/queue/status");
if (!r.ok) return false; if (!r || !r.ok) return false;
const st = await r.json(); const st = await r.json();
return !!(st && st.is_processing); return !!(st && st.is_processing);
} catch { } catch {
@@ -873,7 +1157,17 @@ async function managerIsBusy() {
} }
} }
// Install a pack or re-enable a disabled one (both go through Manager's install
// queue). v4-legacy batches it as {install:[payload]}; v3 posts the payload to
// /manager/queue/install then /manager/queue/start.
async function runManagerEnable(payload) { async function runManagerEnable(payload) {
const api = await detectManagerApi();
if (!api) throw new Error("ComfyUI Manager not available");
if (api.batch) {
await runManagerBatch(api, { install: [payload] });
return;
}
await fetch("/manager/queue/reset", { method: "POST", headers: { "Content-Type": "application/json" } }); await fetch("/manager/queue/reset", { method: "POST", headers: { "Content-Type": "application/json" } });
const r = await fetch("/manager/queue/install", { const r = await fetch("/manager/queue/install", {
@@ -1292,26 +1586,39 @@ async function processExpiredTrials() {
if (r.ok) trials = await r.json(); if (r.ok) trials = await r.json();
} catch { return; } } catch { return; }
const expired = trials.filter((t) => t.expired); let expired = trials.filter((t) => t.expired);
if (!expired.length) return;
// Whitelisted packs are protected: never auto-disable them. Clear their trial
// row so they stop being tracked as temporary (they're now keepers).
const whitelist = await fetchWhitelist();
const protectedExpired = expired.filter((t) => whitelist.has(t.package));
for (const t of protectedExpired) await stopTrial(t.package);
expired = expired.filter((t) => !whitelist.has(t.package));
if (!expired.length) return; if (!expired.length) return;
const mgr = await fetchManagerInfo(); const mgr = await fetchManagerInfo();
if (!mgr) return; // Manager unavailable — leave rows for a later session
// Don't reset Manager's queue out from under an in-progress operation // Don't reset Manager's queue out from under an in-progress operation
// (e.g. startup install work); the expired rows persist and retry next session. // (e.g. startup install work); the expired rows persist and retry next session.
if (await managerIsBusy()) return; if (mgr && await managerIsBusy()) return;
const done = []; const done = [];
for (const t of expired) { for (const t of expired) {
const info = mgr[t.package]; const info = mgr && mgr[t.package];
if (!info || info.state === "disabled") { if (info && info.state === "disabled") {
await stopTrial(t.package); await stopTrial(t.package);
done.push(t.package); done.push(t.package);
continue; continue;
} }
try { try {
await runManagerDisable([disablePayload(t.package, info)]); if (info) {
await runManagerDisable([disablePayload(t.package, info)]);
} else {
// Manager absent or doesn't know this pack — disable it natively.
const res = await runNativeDisable([t.package]);
if (!res.succeeded.includes(t.package)) throw new Error("native disable failed");
}
await stopTrial(t.package); await stopTrial(t.package);
done.push(t.package); done.push(t.package);
} catch { /* keep the row; retry next session */ } } catch { /* keep the row; retry next session */ }
@@ -1328,8 +1635,8 @@ async function waitForQueue(timeoutMs = 60000) {
while (Date.now() < deadline) { while (Date.now() < deadline) {
let st = null; let st = null;
try { try {
const r = await fetch("/manager/queue/status"); const r = await mgrFetch("/manager/queue/status");
if (r.ok) st = await r.json(); if (r && r.ok) st = await r.json();
} catch { /* transient; retry */ } } catch { /* transient; retry */ }
if (st && !st.is_processing && st.in_progress_count === 0) return; if (st && !st.is_processing && st.in_progress_count === 0) return;
await sleep(500); await sleep(500);
@@ -1355,13 +1662,13 @@ function markPackagesDisabled(dialog, pkgNames) {
} }
} }
// Recompute "Disable all (N)" counts after a batch; hide buttons with nothing // Recompute "Disable all (N)" counts after a batch, dropping packages that were
// left to disable. // just disabled; hide buttons with nothing left. Mode-agnostic (Manager+native).
function updateBulkButtons(dialog, managerInfo) { function updateBulkButtons(dialog, succeededSet) {
dialog.querySelectorAll(".ns-disable-all-btn").forEach((btn) => { dialog.querySelectorAll(".ns-disable-all-btn").forEach((btn) => {
let names = []; let names = [];
try { names = JSON.parse(btn.dataset.pkgs); } catch { names = []; } try { names = JSON.parse(btn.dataset.pkgs); } catch { names = []; }
const remaining = names.filter((n) => managerInfo[n] && managerInfo[n].state !== "disabled"); const remaining = names.filter((n) => !succeededSet.has(n));
if (remaining.length === 0) { if (remaining.length === 0) {
btn.style.display = "none"; btn.style.display = "none";
} else { } else {
@@ -1393,10 +1700,18 @@ function showRestartBanner(dialog) {
} }
async function rebootComfy() { async function rebootComfy() {
// The reboot endpoint is provided by ComfyUI Manager. Without it we can't
// restart the server ourselves — tell the user to do it manually (this is the
// native-only disable path).
const api = await detectManagerApi();
if (!api) {
notify("No ComfyUI Manager to trigger a restart — please restart ComfyUI manually to apply the changes.", "warn");
return;
}
if (!confirm("Restart ComfyUI now? The server will go down briefly and the page will reconnect.")) return; if (!confirm("Restart ComfyUI now? The server will go down briefly and the page will reconnect.")) return;
notify("Restarting ComfyUI…", "info"); notify("Restarting ComfyUI…", "info");
try { try {
await fetch("/manager/reboot", { method: "POST", headers: { "Content-Type": "application/json" } }); await mgrFetch("/manager/reboot", { method: "POST", headers: { "Content-Type": "application/json" } });
} catch { } catch {
// The reboot tears down the connection, so a network error here is expected. // The reboot tears down the connection, so a network error here is expected.
} }
+138
View File
@@ -0,0 +1,138 @@
"""Native enable/disable of custom-node packages, independent of ComfyUI Manager.
Disabling a pack is just a filesystem convention that ComfyUI core honors: a
directory (or ``*.py`` file) moved into ``custom_nodes/.disabled/`` is skipped at
boot, because core ignores any entry named ``.disabled`` / starting with a dot.
Manager uses the same convention. We only *move* files here — never import,
delete, or re-clone — so the operation is reversible and safe.
This is the fallback the extension uses when ComfyUI Manager is absent or does
not recognize a pack (a hand-cloned repo, an oddly-installed pack, or a loose
single-file node). A restart is required for ComfyUI to pick up the change,
exactly as with a Manager disable.
"""
import logging
import os
import shutil
logger = logging.getLogger(__name__)
# find_disabled_pack_path already knows how to locate a pack under .disabled/,
# including Manager's @version suffix. Import it in a way that works both as a
# package (runtime) and as a top-level module (tests add the root to sys.path).
try:
from .node_introspect import find_disabled_pack_path
except ImportError: # pragma: no cover - exercised via top-level import in tests
from node_introspect import find_disabled_pack_path
def _custom_node_roots():
import folder_paths
return [os.path.normpath(r) for r in folder_paths.get_folder_paths("custom_nodes")]
def _valid_name(pack_name):
"""Reject path-y input; pack names are plain directory/file names."""
return bool(pack_name) and not any(c in pack_name for c in ("/", "\\")) and ".." not in pack_name
def list_disabled_packs():
"""Return clean names of packs sitting in any custom_nodes/.disabled/ dir.
Strips a Manager ``@version`` suffix and a ``.py`` extension so the names
match package names as reported in the stats (and accepted by
``enable_pack_native``).
"""
names = set()
for root in _custom_node_roots():
ddir = os.path.join(root, ".disabled")
try:
entries = os.listdir(ddir)
except Exception:
continue
for e in entries:
if e.startswith("."):
continue
base = e.split("@", 1)[0]
stem = base[:-3] if base.endswith(".py") else base
if stem:
names.add(stem)
return names
def find_active_pack_path(pack_name):
"""Locate an installed (active) pack directly under a custom_nodes root.
Returns an absolute path to the pack directory or single ``.py`` file, or
None. Matches case-insensitively and tolerates a Manager ``@version`` suffix.
Only real packs (a directory, or a ``.py`` file) qualify.
"""
if not _valid_name(pack_name):
return None
target = pack_name.lower()
for root in _custom_node_roots():
try:
entries = os.listdir(root)
except Exception:
continue
for e in entries:
if e.startswith("."):
continue
base = e.split("@", 1)[0]
stem = base[:-3] if base.endswith(".py") else base
if target not in (stem.lower(), base.lower(), e.lower()):
continue
full = os.path.join(root, e)
if os.path.isdir(full) or (os.path.isfile(full) and full.endswith(".py")):
return os.path.normpath(full)
return None
def disable_pack_native(pack_name):
"""Move an active pack into ``custom_nodes/.disabled/``.
Returns ``(ok, message)``. Never raises for expected conditions (pack not
found, collision, permission error); those come back as ``(False, reason)``.
"""
src = find_active_pack_path(pack_name)
if not src:
return False, "pack not found on disk"
root = os.path.dirname(src)
ddir = os.path.join(root, ".disabled")
dest = os.path.join(ddir, os.path.basename(src))
if os.path.exists(dest):
return False, "a disabled copy already exists at " + dest
try:
os.makedirs(ddir, exist_ok=True)
shutil.move(src, dest)
except Exception as e:
logger.warning("nodes-stats: native disable failed for %s", pack_name, exc_info=True)
return False, str(e)
return True, "disabled"
def enable_pack_native(pack_name):
"""Move a pack from ``custom_nodes/.disabled/`` back to its root.
Returns ``(ok, message)``. Drops any Manager ``@version`` suffix from the
destination so the pack lands as a clean, importable directory name — the
same shape Manager itself restores on enable (``ComfyMath@nightly`` ->
``ComfyMath``). A single ``.py`` file keeps its extension.
"""
src = find_disabled_pack_path(pack_name)
if not src:
return False, "disabled pack not found"
ddir = os.path.dirname(src) # .../custom_nodes/.disabled
root = os.path.dirname(ddir) # .../custom_nodes
dest_name = os.path.basename(src).split("@", 1)[0]
dest = os.path.join(root, dest_name)
if os.path.exists(dest):
return False, "an active copy already exists at " + dest
try:
shutil.move(src, dest)
except Exception as e:
logger.warning("nodes-stats: native enable failed for %s", pack_name, exc_info=True)
return False, str(e)
return True, "enabled"
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "comfyui-nodes-stats" name = "comfyui-nodes-stats"
description = "Track usage statistics for all ComfyUI nodes and packages" description = "Track usage statistics for all ComfyUI nodes and packages"
version = "1.7.0" version = "1.8.0"
license = "MIT" license = "MIT"
[project.urls] [project.urls]
+150
View File
@@ -0,0 +1,150 @@
import os
import pytest
import pack_fs
@pytest.fixture
def custom_nodes(tmp_path):
"""A custom_nodes root wired into the mocked folder_paths."""
import folder_paths
root = tmp_path / "custom_nodes"
root.mkdir()
folder_paths.get_folder_paths.return_value = [str(root)]
return root
def _make_pack(root, name):
pack = root / name
pack.mkdir()
(pack / "__init__.py").write_text("NODE_CLASS_MAPPINGS = {}\n")
return pack
# --- find_active_pack_path ------------------------------------------------
def test_find_active_dir(custom_nodes):
_make_pack(custom_nodes, "MyPack")
found = pack_fs.find_active_pack_path("MyPack")
assert found == os.path.normpath(str(custom_nodes / "MyPack"))
def test_find_active_case_insensitive(custom_nodes):
_make_pack(custom_nodes, "MyPack")
assert pack_fs.find_active_pack_path("mypack") is not None
def test_find_active_single_py_file(custom_nodes):
(custom_nodes / "loose_node.py").write_text("NODE_CLASS_MAPPINGS = {}\n")
found = pack_fs.find_active_pack_path("loose_node")
assert found == os.path.normpath(str(custom_nodes / "loose_node.py"))
def test_find_active_ignores_disabled_dir(custom_nodes):
(custom_nodes / ".disabled").mkdir()
_make_pack(custom_nodes / ".disabled", "MyPack")
assert pack_fs.find_active_pack_path("MyPack") is None
def test_find_active_rejects_path_traversal(custom_nodes):
assert pack_fs.find_active_pack_path("../evil") is None
assert pack_fs.find_active_pack_path("a/b") is None
assert pack_fs.find_active_pack_path("") is None
# --- disable_pack_native --------------------------------------------------
def test_disable_moves_dir_into_disabled(custom_nodes):
_make_pack(custom_nodes, "MyPack")
ok, msg = pack_fs.disable_pack_native("MyPack")
assert ok, msg
assert not (custom_nodes / "MyPack").exists()
assert (custom_nodes / ".disabled" / "MyPack" / "__init__.py").exists()
def test_disable_moves_single_py_file(custom_nodes):
(custom_nodes / "loose_node.py").write_text("x = 1\n")
ok, msg = pack_fs.disable_pack_native("loose_node")
assert ok, msg
assert not (custom_nodes / "loose_node.py").exists()
assert (custom_nodes / ".disabled" / "loose_node.py").exists()
def test_disable_missing_pack_fails(custom_nodes):
ok, msg = pack_fs.disable_pack_native("Ghost")
assert not ok
assert "not found" in msg
def test_disable_collision_fails(custom_nodes):
_make_pack(custom_nodes, "MyPack")
(custom_nodes / ".disabled").mkdir()
(custom_nodes / ".disabled" / "MyPack").mkdir() # pre-existing disabled copy
ok, msg = pack_fs.disable_pack_native("MyPack")
assert not ok
assert "already exists" in msg
# original left untouched
assert (custom_nodes / "MyPack").exists()
# --- enable_pack_native ---------------------------------------------------
def test_enable_moves_back(custom_nodes):
_make_pack(custom_nodes, "MyPack")
assert pack_fs.disable_pack_native("MyPack")[0]
ok, msg = pack_fs.enable_pack_native("MyPack")
assert ok, msg
assert (custom_nodes / "MyPack" / "__init__.py").exists()
assert not (custom_nodes / ".disabled" / "MyPack").exists()
def test_enable_missing_fails(custom_nodes):
ok, msg = pack_fs.enable_pack_native("Ghost")
assert not ok
assert "not found" in msg
def test_enable_strips_version_suffix(custom_nodes):
# A Manager-disabled pack on disk carries an @version suffix; enabling should
# restore it as a clean, importable directory name.
ddir = custom_nodes / ".disabled"
ddir.mkdir()
pack = ddir / "ComfyMath@nightly"
pack.mkdir()
(pack / "__init__.py").write_text("NODE_CLASS_MAPPINGS = {}\n")
ok, msg = pack_fs.enable_pack_native("ComfyMath")
assert ok, msg
assert (custom_nodes / "ComfyMath" / "__init__.py").exists()
assert not (custom_nodes / "ComfyMath@nightly").exists()
assert not (ddir / "ComfyMath@nightly").exists()
def test_disable_then_enable_roundtrip_py_file(custom_nodes):
(custom_nodes / "loose_node.py").write_text("x = 1\n")
assert pack_fs.disable_pack_native("loose_node")[0]
assert pack_fs.enable_pack_native("loose_node")[0]
assert (custom_nodes / "loose_node.py").exists()
# --- list_disabled_packs --------------------------------------------------
def test_list_disabled_empty(custom_nodes):
assert pack_fs.list_disabled_packs() == set()
def test_list_disabled_strips_version_and_ext(custom_nodes):
ddir = custom_nodes / ".disabled"
ddir.mkdir()
(ddir / "ComfyMath@nightly").mkdir() # Manager-style @version suffix
(ddir / "PlainPack").mkdir()
(ddir / "loose_node.py").write_text("x = 1\n") # single-file pack
assert pack_fs.list_disabled_packs() == {"ComfyMath", "PlainPack", "loose_node"}
def test_list_disabled_reflects_a_native_disable(custom_nodes):
_make_pack(custom_nodes, "MyPack")
assert pack_fs.disable_pack_native("MyPack")[0]
assert "MyPack" in pack_fs.list_disabled_packs()
+72
View File
@@ -0,0 +1,72 @@
import pytest
from tracker import UsageTracker
@pytest.fixture
def tracker(tmp_path):
return UsageTracker(db_path=str(tmp_path / "test.db"))
def test_whitelist_starts_empty(tracker):
assert tracker.get_whitelist() == set()
def test_add_and_get(tracker):
tracker.add_to_whitelist("My-Pack")
assert tracker.get_whitelist() == {"My-Pack"}
def test_add_is_idempotent(tracker):
tracker.add_to_whitelist("My-Pack")
tracker.add_to_whitelist("My-Pack")
assert tracker.get_whitelist() == {"My-Pack"}
def test_remove(tracker):
tracker.add_to_whitelist("My-Pack")
tracker.remove_from_whitelist("My-Pack")
assert tracker.get_whitelist() == set()
def test_remove_absent_is_noop(tracker):
tracker.remove_from_whitelist("Nope") # must not raise
assert tracker.get_whitelist() == set()
def test_reset_clears_whitelist(tracker):
tracker.add_to_whitelist("My-Pack")
tracker.reset()
assert tracker.get_whitelist() == set()
class _Mapper:
"""Minimal stand-in for NodePackageMapper with a fixed mapping."""
def __init__(self, mapping):
self.mapping = mapping
def get_package(self, ct):
return self.mapping.get(ct, "__unknown__")
def get_all_packages(self):
return set(self.mapping.values()) - {"__builtin__"}
def test_package_stats_flags_whitelisted(tracker):
mapper = _Mapper({"NodeA": "Pack-A", "NodeB": "Pack-B"})
tracker.record_usage(["NodeA", "NodeB"], mapper)
tracker.add_to_whitelist("Pack-A")
stats = {p["package"]: p for p in tracker.get_package_stats(mapper)}
assert stats["Pack-A"]["whitelisted"] is True
assert stats["Pack-B"]["whitelisted"] is False
def test_package_stats_whitelist_is_case_insensitive(tracker):
mapper = _Mapper({"NodeA": "Pack-A"})
tracker.record_usage(["NodeA"], mapper)
tracker.add_to_whitelist("pack-a") # different case than the package name
stats = {p["package"]: p for p in tracker.get_package_stats(mapper)}
assert stats["Pack-A"]["whitelisted"] is True
+51
View File
@@ -57,6 +57,11 @@ CREATE TABLE IF NOT EXISTS trial_packages (
budget INTEGER NOT NULL DEFAULT 7 budget INTEGER NOT NULL DEFAULT 7
); );
CREATE TABLE IF NOT EXISTS whitelist_packages (
package TEXT PRIMARY KEY,
added_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_node_usage_package ON node_usage(package); 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); CREATE INDEX IF NOT EXISTS idx_prompt_log_timestamp ON prompt_log(timestamp);
CREATE INDEX IF NOT EXISTS idx_model_usage_type ON model_usage(model_type); CREATE INDEX IF NOT EXISTS idx_model_usage_type ON model_usage(model_type);
@@ -286,6 +291,13 @@ class UsageTracker:
tracking_start, one_month_ago, two_months_ago, "unused_new" tracking_start, one_month_ago, two_months_ago, "unused_new"
) )
# Flag whitelisted packages so the UI can pull them into their own group
# and suppress disable actions. Whitelist entries are kept verbatim; match
# case-insensitively since directory names vary by how users clone/symlink.
whitelist = {w.lower() for w in self.get_whitelist()}
for entry in packages.values():
entry["whitelisted"] = entry["package"].lower() in whitelist
result = [p for p in packages.values() if p["package"].lower() not in EXCLUDED_PACKAGES] result = [p for p in packages.values() if p["package"].lower() not in EXCLUDED_PACKAGES]
result.sort(key=lambda p: p["total_executions"]) result.sort(key=lambda p: p["total_executions"])
return result return result
@@ -480,6 +492,44 @@ class UsageTracker:
finally: finally:
conn.close() conn.close()
def add_to_whitelist(self, package):
"""Protect a package: keep it out of disable actions and its own group."""
now = datetime.now(timezone.utc).isoformat()
with self._lock:
self._ensure_db()
conn = self._connect()
try:
conn.execute(
"""INSERT INTO whitelist_packages (package, added_at) VALUES (?, ?)
ON CONFLICT(package) DO NOTHING""",
(package, now),
)
conn.commit()
finally:
conn.close()
def remove_from_whitelist(self, package):
"""Remove a package from the whitelist."""
with self._lock:
self._ensure_db()
conn = self._connect()
try:
conn.execute("DELETE FROM whitelist_packages WHERE package = ?", (package,))
conn.commit()
finally:
conn.close()
def get_whitelist(self):
"""Return the set of whitelisted package names."""
with self._lock:
self._ensure_db()
conn = self._connect()
try:
rows = conn.execute("SELECT package FROM whitelist_packages").fetchall()
return {r[0] for r in rows}
finally:
conn.close()
def reset(self): def reset(self):
"""Clear all tracked data.""" """Clear all tracked data."""
with self._lock: with self._lock:
@@ -490,6 +540,7 @@ class UsageTracker:
conn.execute("DELETE FROM prompt_log") conn.execute("DELETE FROM prompt_log")
conn.execute("DELETE FROM model_usage") conn.execute("DELETE FROM model_usage")
conn.execute("DELETE FROM trial_packages") conn.execute("DELETE FROM trial_packages")
conn.execute("DELETE FROM whitelist_packages")
conn.commit() conn.commit()
finally: finally:
conn.close() conn.close()