Compare commits

...
2 Commits
Author SHA1 Message Date
Ethanfel 54f9a3ae29 feat(models): add bulk model deletion controls 2026-07-29 20:00:23 +02:00
Ethanfel 228542c71a fix: keep disabled unused packages manageable 2026-07-29 20:00:23 +02:00
7 changed files with 359 additions and 23 deletions
+14 -9
View File
@@ -67,27 +67,31 @@ Click the **Node Stats** button (bar chart icon) in the ComfyUI top menu bar. A
- Summary bar with counts for each classification tier - Summary bar with counts for each classification tier
- Sections for each tier, sorted from most actionable to least - Sections for each tier, sorted from most actionable to least
- Expandable rows — click any package to see per-node execution counts and timestamps - 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) - **Disable** buttons on the "Safe to Remove", "Consider Removing", and "Recently Unused" tiers (see below)
### Disabling unused packages ### Disabling unused packages
When [ComfyUI Manager](https://github.com/ltdrdata/ComfyUI-Manager) is installed, the The "Safe to Remove", "Consider Removing", and "Recently Unused" sections show
"Safe to Remove" and "Consider Removing" sections show a **Disable** button on each a **Disable** button on each package, plus a **Disable all** button per section.
package, plus a **Disable all** button per section. Disabling: The latter is useful for a package you installed but never used. Disabling:
- Hands off to ComfyUI Manager, which moves the package into `custom_nodes/.disabled/` - Uses ComfyUI Manager when it manages the package, otherwise moves it directly
- Is fully reversible — re-enable any package from ComfyUI Manager whenever you like into `custom_nodes/.disabled/`
- Is fully reversible — the *Uninstalled* section offers **Enable** for every
package found in `.disabled/`, even if it was never run
- Requires a ComfyUI restart to unload the package from the running session (a banner - Requires a ComfyUI restart to unload the package from the running session (a banner
with a **Restart ComfyUI** button appears after disabling) with a **Restart ComfyUI** button appears after disabling)
If ComfyUI Manager is not installed, the disable buttons are hidden and stats work as before. ComfyUI Manager is optional for these actions; packages it does not manage are
disabled and re-enabled directly by Node Stats.
> **Manager compatibility:** works with both the standalone > **Manager compatibility:** works with both the standalone
> [ComfyUI-Manager](https://github.com/ltdrdata/ComfyUI-Manager) custom node and > [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 > 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 > `--enable-manager-legacy-ui` (the extension detects the active manager and its
> API automatically). Without a reachable manager, all enable/disable/install > API automatically). Without a reachable manager, Node Stats still supports
> actions are simply omitted. > direct enable/disable; Manager-only workflow install and trial actions are
> unavailable.
### Workflow tab & temporary enable ### Workflow tab & temporary enable
@@ -158,6 +162,7 @@ a clear message) when ComfyUI Manager is absent or there are no disabled package
- Summary bar with counts for each tier across all model types - Summary bar with counts for each tier across all model types
- Sections per model type (checkpoints, vae, controlnet, …) - Sections per model type (checkpoints, vae, controlnet, …)
- Per-model table showing execution count, last used date, and status - Per-model table showing execution count, last used date, and status
- Select one or more installed models and permanently delete them after confirmation
### API ### API
+33 -1
View File
@@ -78,7 +78,13 @@ routes = PromptServer.instance.routes
@routes.get("/nodes-stats/packages") @routes.get("/nodes-stats/packages")
async def get_package_stats(request): async def get_package_stats(request):
try: try:
stats = tracker.get_package_stats(mapper) # Include entries parked in custom_nodes/.disabled even when they were
# never executed. They are otherwise absent from both the DB and the
# live node mapper, leaving no way to re-enable them in the UI.
disabled = await asyncio.get_event_loop().run_in_executor(
None, list_disabled_packs
)
stats = tracker.get_package_stats(mapper, disabled)
return web.json_response(stats) return web.json_response(stats)
except Exception: except Exception:
logger.error("nodes-stats: error getting package stats", exc_info=True) logger.error("nodes-stats: error getting package stats", exc_info=True)
@@ -106,6 +112,32 @@ async def get_model_stats(request):
return web.json_response({"error": "internal error"}, status=500) return web.json_response({"error": "internal error"}, status=500)
@routes.post("/nodes-stats/models/delete")
async def delete_models(request):
"""Delete one or more currently installed model files.
The mapper validates each supplied (type, name) pair against ComfyUI's
current model folders before unlinking it, rather than trusting a path sent
by the browser.
"""
try:
data = await request.json()
models = data.get("models")
if not isinstance(models, list) or not models:
return web.json_response({"error": "models must be a non-empty list"}, status=400)
if len(models) > 500:
return web.json_response({"error": "too many models"}, status=400)
results = await asyncio.get_event_loop().run_in_executor(
None, model_mapper.delete_models, models
)
deleted = [r for r in results if r.get("status") == "deleted"]
failed = [r for r in results if r.get("status") != "deleted"]
return web.json_response({"deleted": deleted, "failed": failed})
except Exception:
logger.error("nodes-stats: error deleting models", exc_info=True)
return web.json_response({"error": "internal error"}, status=500)
@routes.post("/nodes-stats/reset") @routes.post("/nodes-stats/reset")
async def reset_stats(request): async def reset_stats(request):
try: try:
+100 -8
View File
@@ -21,8 +21,10 @@ const STATUS_META = {
uninstalled: { label: "uninstalled", color: "#555", bg: "#1a1a1a", hover: "#252525", summaryBg: "#1a1a1a", summaryText: "#888" }, 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). // Tiers that may offer a "Disable" action. A package never used during the
const DISABLEABLE_TIERS = new Set(["safe_to_remove", "consider_removing"]); // first month is safe to disable manually too; waiting a month would otherwise
// strand common one-off installs such as VAE Decode PlusPlus.
const DISABLEABLE_TIERS = new Set(["safe_to_remove", "consider_removing", "unused_new"]);
// Setting id for the auto-open-on-load behavior. Defaults to off so loading a // 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. // workflow with missing/disabled nodes no longer pops the dialog every time.
@@ -256,6 +258,7 @@ async function showStatsDialog(initialTab = "nodes") {
wireWorkflowButtons(dialog); wireWorkflowButtons(dialog);
wireWhitelistButtons(dialog); wireWhitelistButtons(dialog);
wireNativeEnableButtons(dialog); wireNativeEnableButtons(dialog);
wireModelDeleteControls(dialog);
switchTab(TABS.includes(initialTab) ? initialTab : "nodes"); switchTab(TABS.includes(initialTab) ? initialTab : "nodes");
@@ -430,6 +433,11 @@ function buildModelsTabContent(modelData) {
return html; return html;
} }
html += `<div id="ns-model-actions" style="display:flex;align-items:center;justify-content:flex-end;gap:10px;flex-wrap:wrap;margin:-4px 0 12px;">
<span id="ns-model-selection-count" style="color:#888;font-size:12px;">No models selected</span>
<button id="ns-delete-selected-models" class="ns-btn" disabled>Delete selected</button>
</div>`;
for (const group of modelData) { for (const group of modelData) {
if (group.models.length === 0) continue; if (group.models.length === 0) continue;
const title = group.model_type.charAt(0).toUpperCase() + group.model_type.slice(1).replace(/_/g, " "); const title = group.model_type.charAt(0).toUpperCase() + group.model_type.slice(1).replace(/_/g, " ");
@@ -441,30 +449,114 @@ function buildModelsTabContent(modelData) {
} }
function buildModelTable(models) { function buildModelTable(models) {
let html = `<table style="width:100%;border-collapse:collapse;margin-bottom:12px;"> let html = `<div style="max-width:100%;overflow-x:auto;margin-bottom:12px;"><table style="width:100%;min-width:620px;table-layout:fixed;border-collapse:collapse;">
<thead><tr style="color:#888;text-align:left;border-bottom:1px solid #333;"> <thead><tr style="color:#888;text-align:left;border-bottom:1px solid #333;">
<th style="padding:6px 8px;width:28px;"><input class="ns-model-select-all" type="checkbox" title="Select all installed models in this group" aria-label="Select all installed models in this group"></th>
<th style="padding:6px 8px;">Model</th> <th style="padding:6px 8px;">Model</th>
<th style="padding:6px 8px;text-align:right;">Executions</th> <th style="padding:6px 8px;width:82px;text-align:right;">Executions</th>
<th style="padding:6px 8px;">Last Used</th> <th style="padding:6px 8px;width:76px;">Last Used</th>
<th style="padding:6px 8px;">Status</th> <th style="padding:6px 8px;width:112px;">Status</th>
<th style="padding:6px 8px;width:68px;text-align:right;"></th>
</tr></thead><tbody>`; </tr></thead><tbody>`;
for (const m of models) { for (const m of models) {
const meta = STATUS_META[m.status] || STATUS_META.used; const meta = STATUS_META[m.status] || STATUS_META.used;
const lastSeen = m.last_seen ? new Date(m.last_seen).toLocaleDateString() : "—"; const lastSeen = m.last_seen ? new Date(m.last_seen).toLocaleDateString() : "—";
const selectable = m.installed ? "" : " disabled";
const action = m.installed
? `<button class="ns-btn ns-delete-model-btn" data-model-type="${escapeAttr(m.model_type)}" data-model-name="${escapeAttr(m.model_name)}">Delete</button>`
: `<span style="color:#555;">—</span>`;
html += `<tr class="ns-row-${m.status}" style="border-bottom:1px solid #222;"> html += `<tr class="ns-row-${m.status}" style="border-bottom:1px solid #222;">
<td style="padding:6px 8px;color:#fff;">${escapeHtml(m.model_name)}</td> <td style="padding:6px 8px;"><input class="ns-model-select" type="checkbox" data-model-type="${escapeAttr(m.model_type)}" data-model-name="${escapeAttr(m.model_name)}" aria-label="Select ${escapeAttr(m.model_name)}"${selectable}></td>
<td style="padding:6px 8px;color:#fff;overflow-wrap:anywhere;word-break:break-word;">${escapeHtml(m.model_name)}</td>
<td style="padding:6px 8px;text-align:right;">${m.count}</td> <td style="padding:6px 8px;text-align:right;">${m.count}</td>
<td style="padding:6px 8px;color:#888;">${lastSeen}</td> <td style="padding:6px 8px;color:#888;">${lastSeen}</td>
<td style="padding:6px 8px;"><span style="color:${meta.color};font-size:11px;">${meta.label}</span></td> <td style="padding:6px 8px;"><span style="color:${meta.color};font-size:11px;">${meta.label}</span></td>
<td style="padding:6px 8px;text-align:right;white-space:nowrap;">${action}</td>
</tr>`; </tr>`;
} }
html += `</tbody></table>`; html += `</tbody></table></div>`;
return html; return html;
} }
// Model deletion is intentionally separate from package disable: the selected
// file is deleted permanently, so every request is confirmed and the backend
// validates that it belongs to a configured ComfyUI model folder.
function wireModelDeleteControls(dialog) {
const selectionCount = dialog.querySelector("#ns-model-selection-count");
const deleteSelected = dialog.querySelector("#ns-delete-selected-models");
if (!selectionCount || !deleteSelected) return;
const selectedModels = () => [...dialog.querySelectorAll(".ns-model-select:checked")].map((box) => ({
model_type: box.dataset.modelType,
model_name: box.dataset.modelName,
}));
const updateSelection = () => {
const selected = dialog.querySelectorAll(".ns-model-select:checked").length;
selectionCount.textContent = selected ? `${selected} model${selected === 1 ? "" : "s"} selected` : "No models selected";
deleteSelected.disabled = selected === 0;
dialog.querySelectorAll(".ns-model-select-all").forEach((master) => {
const choices = [...master.closest("table").querySelectorAll(".ns-model-select:not(:disabled)")];
const checked = choices.filter((box) => box.checked).length;
master.checked = choices.length > 0 && checked === choices.length;
master.indeterminate = checked > 0 && checked < choices.length;
master.disabled = choices.length === 0;
});
};
dialog.querySelectorAll(".ns-model-select").forEach((box) => box.addEventListener("change", updateSelection));
dialog.querySelectorAll(".ns-model-select-all").forEach((master) => master.addEventListener("change", () => {
master.closest("table").querySelectorAll(".ns-model-select:not(:disabled)").forEach((box) => {
box.checked = master.checked;
});
updateSelection();
}));
dialog.querySelectorAll(".ns-delete-model-btn").forEach((button) => button.addEventListener("click", (event) => {
event.stopPropagation();
deleteModels([{ model_type: button.dataset.modelType, model_name: button.dataset.modelName }], dialog);
}));
deleteSelected.addEventListener("click", () => deleteModels(selectedModels(), dialog));
updateSelection();
}
async function deleteModels(models, dialog) {
if (!models.length) return;
const label = models.length === 1 ? `"${models[0].model_name}"` : `${models.length} selected models`;
if (!confirm(`Permanently delete ${label}?\n\nThis removes the model files from disk and cannot be undone.`)) return;
const controls = dialog.querySelectorAll(".ns-model-select, .ns-model-select-all, .ns-delete-model-btn, #ns-delete-selected-models");
controls.forEach((control) => { control.disabled = true; });
try {
const response = await fetch("/nodes-stats/models/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ models }),
});
let result = {};
try { result = await response.json(); } catch { /* use the HTTP status below */ }
if (!response.ok) throw new Error(result.error || `HTTP ${response.status}`);
const deleted = result.deleted || [];
const failed = result.failed || [];
if (deleted.length) {
notify(`Deleted ${deleted.length} model${deleted.length === 1 ? "" : "s"}.`, "success");
showStatsDialog("models"); // Re-read folders; deleted files remain as historical entries.
}
if (failed.length) {
const names = failed.map((item) => item.model_name || item.message || "unknown").join(", ");
notify(`Could not delete: ${names}`, "error");
}
} catch (error) {
notify("Failed to delete model: " + error.message, "error");
} finally {
if (dialog.isConnected) controls.forEach((control) => { control.disabled = false; });
}
}
// Render the Workflow tab from a classification result. `disabled` entries get // Render the Workflow tab from a classification result. `disabled` entries get
// re-enable actions (temporary trial or permanent); `missing` entries get an // re-enable actions (temporary trial or permanent); `missing` entries get an
// Install button that defers to ComfyUI Manager. // Install button that defers to ComfyUI Manager.
+70
View File
@@ -132,6 +132,76 @@ class ModelMapper:
self._ensure() self._ensure()
return {k: sorted(v) for k, v in self._folder_files.items()} return {k: sorted(v) for k, v in self._folder_files.items()}
def delete_models(self, models):
"""Delete verified model files and return per-model results.
Each requested file must still be in the mapper's known model list and
resolve beneath the configured root for its model type. This keeps the
HTTP endpoint from being usable to remove arbitrary files.
"""
self._ensure()
try:
import folder_paths
except ImportError:
return [{"status": "error", "message": "folder_paths unavailable"}]
results = []
seen = set()
for item in models:
if not isinstance(item, dict):
results.append({"status": "error", "message": "invalid model entry"})
continue
model_type = item.get("model_type")
model_name = item.get("model_name")
if not isinstance(model_type, str) or not isinstance(model_name, str):
results.append({
"model_type": model_type,
"model_name": model_name,
"status": "error",
"message": "invalid model entry",
})
continue
key = (model_type, model_name)
if key in seen:
continue
seen.add(key)
result = {"model_type": model_type, "model_name": model_name}
known = self._folder_files.get(model_type, frozenset())
if model_name not in known:
result.update(status="error", message="model is not installed")
results.append(result)
continue
try:
path = folder_paths.get_full_path(model_type, model_name)
roots = folder_paths.get_folder_paths(model_type)
path = os.path.abspath(path) if path else None
allowed = any(
os.path.commonpath([path, os.path.abspath(root)]) == os.path.abspath(root)
for root in roots
) if path else False
except (OSError, TypeError, ValueError):
path = None
allowed = False
if not path or not allowed or not os.path.isfile(path):
result.update(status="error", message="model file not found in its configured folder")
results.append(result)
continue
try:
os.remove(path)
result["status"] = "deleted"
except OSError as exc:
result.update(status="error", message=str(exc))
results.append(result)
# The model-folder listing is now stale whether any deletion succeeded
# or not (another process could have changed it), so rebuild next time.
self.invalidate()
return results
def extract_models_from_prompt(self, prompt): def extract_models_from_prompt(self, prompt):
"""Scan a prompt dict and return (model_name, model_type) pairs. """Scan a prompt dict and return (model_name, model_type) pairs.
+45
View File
@@ -0,0 +1,45 @@
from tracker import UsageTracker
class _Mapper:
def __init__(self, mapping=()):
self.mapping = dict(mapping)
def get_package(self, class_type):
return self.mapping.get(class_type, "__unknown__")
def get_all_packages(self):
return set(self.mapping.values()) - {"__builtin__"}
def test_never_used_disabled_package_stays_visible_for_enable(tmp_path):
tracker = UsageTracker(db_path=str(tmp_path / "test.db"))
stats = tracker.get_package_stats(
_Mapper(), disabled_packages={"VAE-Decode-PlusPlus"}
)
assert stats == [{
"package": "VAE-Decode-PlusPlus",
"total_executions": 0,
"used_nodes": 0,
"nodes": [],
"last_seen": None,
"total_nodes": 0,
"installed": False,
"status": "uninstalled",
"whitelisted": False,
}]
def test_disabled_entry_does_not_override_an_active_package(tmp_path):
tracker = UsageTracker(db_path=str(tmp_path / "test.db"))
stats = tracker.get_package_stats(
_Mapper({"Node": "VAE-Decode-PlusPlus"}),
disabled_packages={"vae-decode-plusplus"},
)
assert len(stats) == 1
assert stats[0]["package"] == "VAE-Decode-PlusPlus"
assert stats[0]["installed"] is True
+66
View File
@@ -0,0 +1,66 @@
from mapper import ModelMapper
def _configure_model_paths(monkeypatch, tmp_path, filenames):
import folder_paths
models_root = tmp_path / "models"
models_root.mkdir()
for filename in filenames:
target = models_root / filename
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(b"model")
monkeypatch.setattr(folder_paths, "folder_names_and_paths", {"checkpoints": ([], {})})
monkeypatch.setattr(folder_paths, "get_filename_list", lambda model_type: filenames if model_type == "checkpoints" else [])
monkeypatch.setattr(folder_paths, "get_folder_paths", lambda model_type: [str(models_root)] if model_type == "checkpoints" else [])
monkeypatch.setattr(folder_paths, "get_full_path", lambda model_type, filename: str(models_root / filename))
return models_root
def test_delete_models_removes_only_a_known_model(monkeypatch, tmp_path):
models_root = _configure_model_paths(monkeypatch, tmp_path, ["nested/model.safetensors"])
mapper = ModelMapper()
result = mapper.delete_models([{
"model_type": "checkpoints",
"model_name": "nested/model.safetensors",
}])
assert result == [{
"model_type": "checkpoints",
"model_name": "nested/model.safetensors",
"status": "deleted",
}]
assert not (models_root / "nested/model.safetensors").exists()
def test_delete_models_rejects_unknown_name(monkeypatch, tmp_path):
models_root = _configure_model_paths(monkeypatch, tmp_path, ["known.safetensors"])
mapper = ModelMapper()
result = mapper.delete_models([{
"model_type": "checkpoints",
"model_name": "../outside.safetensors",
}])
assert result[0]["status"] == "error"
assert (models_root / "known.safetensors").exists()
def test_delete_models_rejects_path_outside_model_root(monkeypatch, tmp_path):
models_root = _configure_model_paths(monkeypatch, tmp_path, ["known.safetensors"])
outside = tmp_path / "outside.safetensors"
outside.write_bytes(b"do not remove")
import folder_paths
monkeypatch.setattr(folder_paths, "get_full_path", lambda *_: str(outside))
mapper = ModelMapper()
result = mapper.delete_models([{
"model_type": "checkpoints",
"model_name": "known.safetensors",
}])
assert result[0]["status"] == "error"
assert (models_root / "known.safetensors").exists()
assert outside.exists()
+31 -5
View File
@@ -217,8 +217,14 @@ class UsageTracker:
finally: finally:
conn.close() conn.close()
def get_package_stats(self, mapper): def get_package_stats(self, mapper, disabled_packages=()):
"""Aggregate per-package stats combining DB data with known nodes.""" """Aggregate package stats from usage, active nodes, and disabled packs.
``disabled_packages`` is the set of directory names currently parked in
``custom_nodes/.disabled``. Those packages are no longer loaded, so
they cannot appear in the node mapper. Including them here keeps a
never-used package visible long enough for the UI to offer Enable.
"""
node_stats = self.get_node_stats() node_stats = self.get_node_stats()
# Build per-package data from DB # Build per-package data from DB
@@ -263,13 +269,33 @@ class UsageTracker:
} }
packages[pkg]["total_nodes"] = total packages[pkg]["total_nodes"] = total
# Packages only in DB (not in mapper) are uninstalled/disabled # Packages only in DB (not in mapper) are uninstalled/disabled.
# Also add disabled-on-disk packages that have no recorded executions;
# without this, a package disabled before first use disappears from the
# UI and cannot be re-enabled there.
active_package_keys = {pkg.lower() for pkg in node_counts}
existing_package_keys = {pkg.lower() for pkg in packages}
for pkg in disabled_packages:
if not isinstance(pkg, str) or not pkg or pkg.lower() in active_package_keys:
continue
if pkg.lower() in existing_package_keys:
continue
packages[pkg] = {
"package": pkg,
"total_executions": 0,
"used_nodes": 0,
"nodes": [],
"last_seen": None,
"total_nodes": 0,
}
existing_package_keys.add(pkg.lower())
# node_counts already includes all packages from mapper + get_all_packages() # node_counts already includes all packages from mapper + get_all_packages()
installed_packages = set(node_counts.keys()) installed_packages = active_package_keys
for pkg, entry in packages.items(): for pkg, entry in packages.items():
if "total_nodes" not in entry: if "total_nodes" not in entry:
entry["total_nodes"] = entry["used_nodes"] entry["total_nodes"] = entry["used_nodes"]
entry["installed"] = pkg in installed_packages entry["installed"] = pkg.lower() in installed_packages
# Classify packages by usage recency # Classify packages by usage recency
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)