This commit is contained in:
John Cantu 2025-12-14 18:11:34 -08:00
commit 1bdcd1bdbf
9 changed files with 2133 additions and 194 deletions

View File

@ -11,7 +11,10 @@ import threading
import re
import shutil
import git
import glob
import json
from datetime import datetime
from contextlib import contextmanager
from server import PromptServer
import manager_core as core
@ -734,6 +737,86 @@ async def fetch_updates(request):
except:
traceback.print_exc()
return web.Response(status=400)
@routes.get("/customnode/get_node_types_in_workflows")
async def get_node_types_in_workflows(request):
try:
# get our username from the request header
user_id = PromptServer.instance.user_manager.get_request_user_id(request)
# get the base workflow directory (TODO: figure out if non-standard directories are possible, and how to find them)
workflow_files_base_path = os.path.abspath(os.path.join(folder_paths.get_user_directory(), user_id, "workflows"))
logging.debug(f"workflows base path: {workflow_files_base_path}")
# workflow directory doesn't actually exist, return 204 (No Content)
if not os.path.isdir(workflow_files_base_path):
logging.debug("workflows base path doesn't exist - nothing to do...")
return web.Response(status=204)
# get all JSON files under the workflow directory
workflow_file_relative_paths: list[str] = glob.glob(pathname="**/*.json", root_dir=workflow_files_base_path, recursive=True)
logging.debug(f"found the following workflows: {workflow_file_relative_paths}")
# set up our list of workflow/node-lists
workflow_node_mappings: list[dict[str, str | list[str]]] = []
# iterate over each found JSON file
for workflow_file_path in workflow_file_relative_paths:
try:
workflow_file_absolute_path = os.path.abspath(os.path.join(workflow_files_base_path, workflow_file_path))
logging.debug(f"starting work on {workflow_file_absolute_path}")
# load the JSON file
workflow_file_data = json.load(open(workflow_file_absolute_path, "r"))
# make sure there's a nodes key (otherwise this might not actually be a workflow file)
if "nodes" not in workflow_file_data:
logging.warning(f"{workflow_file_path} has no 'nodes' key (possibly invalid?) - skipping...")
# skip to next file
continue
# now this looks like a valid file, so let's get to work
new_mapping = {"workflow_file_name": workflow_file_path}
# we can't use an actual set, because you can't use dicts as set members
node_set = []
# iterate over each node in the workflow
for node in workflow_file_data["nodes"]:
if "id" not in node:
logging.warning(f"Found a node with no ID - possibly corrupt/invalid workflow?")
continue
# if there's no type, throw a warning
if "type" not in node:
logging.warning(f"Node type not found in {workflow_file_path} for node ID {node['id']}")
# skip to next node
continue
node_data_to_return = {"type": node["type"]}
if "properties" not in node:
logging.warning(f"Node ${node['id']} has no properties field - can't determine cnr_id")
else:
for property_key in ["cnr_id", "ver"]:
if property_key in node["properties"]:
node_data_to_return[property_key] = node["properties"][property_key]
# add it to the list for this workflow
if not node_data_to_return in node_set:
node_set.append(node_data_to_return)
# annoyingly, Python can't serialize sets to JSON
new_mapping["node_types"] = list(node_set)
workflow_node_mappings.append(new_mapping)
except Exception as e:
logging.warning(f"Couldn't open {workflow_file_path}: {e}")
return web.json_response(workflow_node_mappings, content_type='application/json')
except:
traceback.print_exc()
return web.Response(status=500)
@routes.get("/manager/queue/update_all")

View File

@ -9,6 +9,7 @@ This directory contains the JavaScript frontend implementation for ComfyUI-Manag
- **model-manager.js**: Handles the model management interface for downloading and organizing AI models.
- **components-manager.js**: Manages reusable workflow components system.
- **snapshot.js**: Implements the snapshot system for backing up and restoring installations.
- **node-usage-analyzer.js**: Implements the UI for analyzing node usage in workflows.
## Sharing Components
@ -46,5 +47,6 @@ The frontend follows a modular component-based architecture:
CSS files are included for specific components:
- **custom-nodes-manager.css**: Styling for the node management UI
- **model-manager.css**: Styling for the model management UI
- **node-usage-analyzer.css**: Styling for the node usage analyzer UI
This frontend implementation provides a comprehensive yet user-friendly interface for managing the ComfyUI ecosystem.

View File

@ -18,6 +18,7 @@ import {
} from "./common.js";
import { ComponentBuilderDialog, getPureName, load_components, set_component_policy } from "./components-manager.js";
import { CustomNodesManager } from "./custom-nodes-manager.js";
import { NodeUsageAnalyzer } from "./node-usage-analyzer.js";
import { ModelManager } from "./model-manager.js";
import { SnapshotManager } from "./snapshot.js";
@ -915,6 +916,17 @@ class ManagerMenuDialog extends ComfyDialog {
CustomNodesManager.instance.show(CustomNodesManager.ShowMode.IN_WORKFLOW);
}
}),
$el("button.cm-button", {
type: "button",
textContent: "Node Usage Analyzer",
onclick:
() => {
if(!NodeUsageAnalyzer.instance) {
NodeUsageAnalyzer.instance = new NodeUsageAnalyzer(app, self);
}
NodeUsageAnalyzer.instance.show(NodeUsageAnalyzer.SortMode.BY_PACKAGE);
}
}),
$el("br", {}, []),
$el("button.cm-button", {

View File

@ -122,9 +122,9 @@ export async function customConfirm(message) {
let res = await
window['app'].extensionManager.dialog
.confirm({
title: 'Confirm',
message: message
});
title: 'Confirm',
message: message
});
return res;
}
@ -164,9 +164,9 @@ export async function customPrompt(title, message) {
let res = await
window['app'].extensionManager.dialog
.prompt({
title: title,
message: message
});
title: title,
message: message
});
return res;
}
@ -667,4 +667,449 @@ function initTooltip () {
document.body.addEventListener('mouseleave', mouseleaveHandler, true);
}
export async function uninstallNodes(nodeList, options = {}) {
const {
title = `${nodeList.length} custom nodes`,
onProgress = () => {},
onError = () => {},
onSuccess = () => {},
channel = 'default',
mode = 'default'
} = options;
// Check if queue is busy
let stats = await api.fetchApi('/manager/queue/status');
stats = await stats.json();
if (stats.is_processing) {
customAlert(`[ComfyUI-Manager] There are already tasks in progress. Please try again after it is completed. (${stats.done_count}/${stats.total_count})`);
return { success: false, error: 'Queue is busy' };
}
// Confirmation dialog for uninstall
const confirmed = await customConfirm(`Are you sure uninstall ${title}?`);
if (!confirmed) {
return { success: false, error: 'User cancelled' };
}
let errorMsg = "";
let target_items = [];
await api.fetchApi('/manager/queue/reset');
for (const nodeItem of nodeList) {
target_items.push(nodeItem);
onProgress(`Uninstall ${nodeItem.title || nodeItem.name} ...`);
const data = nodeItem.originalData || nodeItem;
data.channel = channel;
data.mode = mode;
data.ui_id = nodeItem.hash || md5(nodeItem.name || nodeItem.title);
const res = await api.fetchApi(`/manager/queue/uninstall`, {
method: 'POST',
body: JSON.stringify(data)
});
if (res.status != 200) {
errorMsg = `'${nodeItem.title || nodeItem.name}': `;
if (res.status == 403) {
errorMsg += `This action is not allowed with this security level configuration.\n`;
} else if (res.status == 404) {
errorMsg += `With the current security level configuration, only custom nodes from the <B>"default channel"</B> can be uninstalled.\n`;
} else {
errorMsg += await res.text() + '\n';
}
break;
}
}
if (errorMsg) {
onError(errorMsg);
show_message("[Uninstall Errors]\n" + errorMsg);
return { success: false, error: errorMsg, targets: target_items };
} else {
await api.fetchApi('/manager/queue/start');
onSuccess(target_items);
showTerminal();
return { success: true, targets: target_items };
}
}
// ===========================================================================================
// Workflow Utilities Consolidation
export async function getWorkflowNodeTypes() {
try {
const res = await fetchData('/customnode/get_node_types_in_workflows');
if (res.status === 200) {
return { success: true, data: res.data };
} else if (res.status === 204) {
// No workflows found - return empty list
return { success: true, data: [] };
} else {
return { success: false, error: res.error };
}
} catch (error) {
return { success: false, error: error };
}
}
export function findPackageByCnrId(cnrId, nodePackages, installedOnly = true) {
if (!cnrId || !nodePackages) {
return null;
}
// Tier 1: Direct key match
if (nodePackages[cnrId]) {
const pack = nodePackages[cnrId];
if (!installedOnly || pack.state !== "not-installed") {
return { key: cnrId, pack: pack };
}
}
// Tier 2: Case-insensitive match
const cnrIdLower = cnrId.toLowerCase();
for (const packKey of Object.keys(nodePackages)) {
if (packKey.toLowerCase() === cnrIdLower) {
const pack = nodePackages[packKey];
if (!installedOnly || pack.state !== "not-installed") {
return { key: packKey, pack: pack };
}
}
}
// Tier 3: URL/reference contains match
for (const packKey of Object.keys(nodePackages)) {
const pack = nodePackages[packKey];
// Skip non-installed packages if installedOnly is true
if (installedOnly && pack.state === "not-installed") {
continue;
}
// Check if reference URL contains cnr_id
if (pack.reference && pack.reference.includes(cnrId)) {
return { key: packKey, pack: pack };
}
// Check if any file URL contains cnr_id
if (pack.files && Array.isArray(pack.files)) {
for (const fileUrl of pack.files) {
if (fileUrl.includes(cnrId)) {
return { key: packKey, pack: pack };
}
}
}
}
return null;
}
export async function analyzeWorkflowUsage(nodePackages) {
const result = await getWorkflowNodeTypes();
if (!result.success) {
return { success: false, error: result.error };
}
const workflowNodeList = result.data;
const usageMap = new Map();
const workflowDetailsMap = new Map();
if (workflowNodeList && Array.isArray(workflowNodeList)) {
const cnrIdCounts = new Map();
const cnrIdToWorkflows = new Map();
// Process each workflow
workflowNodeList.forEach((workflowObj, workflowIndex) => {
if (workflowObj.node_types && Array.isArray(workflowObj.node_types)) {
const workflowCnrIds = new Set();
// Get workflow filename
const workflowFilename = workflowObj.workflow_file_name ||
workflowObj.filename ||
workflowObj.file ||
workflowObj.name ||
workflowObj.path ||
`Workflow ${workflowIndex + 1}`;
// Count nodes per cnr_id in this workflow
const workflowCnrIdCounts = new Map();
workflowObj.node_types.forEach(nodeTypeObj => {
const cnrId = nodeTypeObj.cnr_id;
if (cnrId && cnrId !== "comfy-core") {
// Track unique cnr_ids per workflow
workflowCnrIds.add(cnrId);
// Count nodes per cnr_id in this specific workflow
const workflowNodeCount = workflowCnrIdCounts.get(cnrId) || 0;
workflowCnrIdCounts.set(cnrId, workflowNodeCount + 1);
}
});
// Record workflow details for each unique cnr_id found in this workflow
workflowCnrIds.forEach(cnrId => {
// Count occurrences of this cnr_id across all workflows
const currentCount = cnrIdCounts.get(cnrId) || 0;
cnrIdCounts.set(cnrId, currentCount + 1);
// Track workflow details
if (!cnrIdToWorkflows.has(cnrId)) {
cnrIdToWorkflows.set(cnrId, []);
}
cnrIdToWorkflows.get(cnrId).push({
filename: workflowFilename,
nodeCount: workflowCnrIdCounts.get(cnrId) || 0
});
});
}
});
// Map cnr_id to installed packages with workflow details
cnrIdCounts.forEach((count, cnrId) => {
const workflowDetails = cnrIdToWorkflows.get(cnrId) || [];
const foundPackage = findPackageByCnrId(cnrId, nodePackages, true);
if (foundPackage) {
usageMap.set(foundPackage.key, count);
workflowDetailsMap.set(foundPackage.key, workflowDetails);
}
});
}
return {
success: true,
usageMap: usageMap,
workflowDetailsMap: workflowDetailsMap
};
}
// Size formatting utilities - consolidated from model-manager.js and node-usage-analyzer.js
export function formatSize(v) {
const base = 1000;
const units = ['', 'K', 'M', 'G', 'T', 'P'];
const space = '';
const postfix = 'B';
if (v <= 0) {
return `0${space}${postfix}`;
}
for (let i = 0, l = units.length; i < l; i++) {
const min = Math.pow(base, i);
const max = Math.pow(base, i + 1);
if (v > min && v <= max) {
const unit = units[i];
if (unit) {
const n = v / min;
const nl = n.toString().split('.')[0].length;
const fl = Math.max(3 - nl, 1);
v = n.toFixed(fl);
}
v = v + space + unit + postfix;
break;
}
}
return v;
}
// for size sort
export function sizeToBytes(v) {
if (typeof v === "number") {
return v;
}
if (typeof v === "string") {
const n = parseFloat(v);
const unit = v.replace(/[0-9.B]+/g, "").trim().toUpperCase();
if (unit === "K") {
return n * 1000;
}
if (unit === "M") {
return n * 1000 * 1000;
}
if (unit === "G") {
return n * 1000 * 1000 * 1000;
}
if (unit === "T") {
return n * 1000 * 1000 * 1000 * 1000;
}
}
return v;
}
// Flyover component - consolidated from custom-nodes-manager.js and node-usage-analyzer.js
export function createFlyover(container, options = {}) {
const {
enableHover = false,
hoverHandler = null,
context = null
} = options;
const $flyover = document.createElement("div");
$flyover.className = "cn-flyover";
$flyover.innerHTML = `<div class="cn-flyover-header">
<div class="cn-flyover-close">${icons.arrowRight}</div>
<div class="cn-flyover-title"></div>
<div class="cn-flyover-close">${icons.close}</div>
</div>
<div class="cn-flyover-body"></div>`
container.appendChild($flyover);
const $flyoverTitle = $flyover.querySelector(".cn-flyover-title");
const $flyoverBody = $flyover.querySelector(".cn-flyover-body");
let width = '50%';
let visible = false;
let timeHide;
const closeHandler = (e) => {
if ($flyover === e.target || $flyover.contains(e.target)) {
return;
}
clearTimeout(timeHide);
timeHide = setTimeout(() => {
flyover.hide();
}, 100);
}
const displayHandler = () => {
if (visible) {
$flyover.classList.remove("cn-slide-in-right");
} else {
$flyover.classList.remove("cn-slide-out-right");
$flyover.style.width = '0px';
$flyover.style.display = "none";
}
}
const flyover = {
show: (titleHtml, bodyHtml) => {
clearTimeout(timeHide);
if (context && context.element) {
context.element.removeEventListener("click", closeHandler);
}
$flyoverTitle.innerHTML = titleHtml;
$flyoverBody.innerHTML = bodyHtml;
$flyover.style.display = "block";
$flyover.style.width = width;
if(!visible) {
$flyover.classList.add("cn-slide-in-right");
}
visible = true;
setTimeout(() => {
if (context && context.element) {
context.element.addEventListener("click", closeHandler);
}
}, 100);
},
hide: (now) => {
visible = false;
if (context && context.element) {
context.element.removeEventListener("click", closeHandler);
}
if(now) {
displayHandler();
return;
}
$flyover.classList.add("cn-slide-out-right");
}
}
$flyover.addEventListener("animationend", (e) => {
displayHandler();
});
// Add hover handlers if enabled
if (enableHover && hoverHandler) {
$flyover.addEventListener("mouseenter", hoverHandler, true);
$flyover.addEventListener("mouseleave", hoverHandler, true);
}
$flyover.addEventListener("click", (e) => {
if(e.target.classList.contains("cn-flyover-close")) {
flyover.hide();
return;
}
// Forward other click events to the provided handler or context
if (context && context.handleFlyoverClick) {
context.handleFlyoverClick(e);
}
});
return flyover;
}
// Shared UI State Methods - consolidated from multiple managers
export function createUIStateManager(element, selectors) {
return {
showSelection: (msg) => {
const el = element.querySelector(selectors.selection);
if (el) el.innerHTML = msg;
},
showError: (err) => {
const el = element.querySelector(selectors.message);
if (el) {
const msg = err ? `<font color="red">${err}</font>` : "";
el.innerHTML = msg;
}
},
showMessage: (msg, color) => {
const el = element.querySelector(selectors.message);
if (el) {
if (color) {
msg = `<font color="${color}">${msg}</font>`;
}
el.innerHTML = msg;
}
},
showStatus: (msg, color) => {
const el = element.querySelector(selectors.status);
if (el) {
if (color) {
msg = `<font color="${color}">${msg}</font>`;
}
el.innerHTML = msg;
}
},
showLoading: (grid) => {
if (grid) {
grid.showLoading();
grid.showMask({
opacity: 0.05
});
}
},
hideLoading: (grid) => {
if (grid) {
grid.hideLoading();
grid.hideMask();
}
},
showRefresh: () => {
const el = element.querySelector(selectors.refresh);
if (el) el.style.display = "block";
},
showStop: () => {
const el = element.querySelector(selectors.stop);
if (el) el.style.display = "block";
},
hideStop: () => {
const el = element.querySelector(selectors.stop);
if (el) el.style.display = "none";
}
};
}
initTooltip();

View File

@ -7,7 +7,7 @@ import {
fetchData, md5, icons, show_message, customConfirm, customAlert, customPrompt,
sanitizeHTML, infoToast, showTerminal, setNeedRestart,
storeColumnWidth, restoreColumnWidth, getTimeAgo, copyText, loadCss,
showPopover, hidePopover, handle403Response
showPopover, hidePopover, getWorkflowNodeTypes, findPackageByCnrId, analyzeWorkflowUsage, createFlyover
} from "./common.js";
// https://cenfun.github.io/turbogrid/api.html
@ -54,6 +54,8 @@ const ShowMode = {
FAVORITES: "Favorites",
ALTERNATIVES: "Alternatives",
IN_WORKFLOW: "In Workflow",
USED_IN_ANY_WORKFLOW: "Used In Any Workflow",
NOT_USED_IN_ANY_WORKFLOW: "Installed and Unused",
};
export class CustomNodesManager {
@ -268,6 +270,14 @@ export class CustomNodesManager {
label: "In Workflow",
value: ShowMode.IN_WORKFLOW,
hasData: false
}, {
label: "Used In Any Workflow",
value: ShowMode.USED_IN_ANY_WORKFLOW,
hasData: false
}, {
label: "Installed and Unused",
value: ShowMode.NOT_USED_IN_ANY_WORKFLOW,
hasData: false
}, {
label: "Missing",
value: ShowMode.MISSING,
@ -518,7 +528,11 @@ export class CustomNodesManager {
const grid = new TG.Grid(container);
this.grid = grid;
this.flyover = this.createFlyover(container);
this.flyover = createFlyover(container, {
enableHover: true,
hoverHandler: this.handleFlyoverHover.bind(this),
context: this
});
let prevViewRowsLength = -1;
grid.bind('onUpdated', (e, d) => {
@ -1061,143 +1075,63 @@ export class CustomNodesManager {
hidePopover();
}
createFlyover(container) {
const $flyover = document.createElement("div");
$flyover.className = "cn-flyover";
$flyover.innerHTML = `<div class="cn-flyover-header">
<div class="cn-flyover-close">${icons.arrowRight}</div>
<div class="cn-flyover-title"></div>
<div class="cn-flyover-close">${icons.close}</div>
</div>
<div class="cn-flyover-body"></div>`
container.appendChild($flyover);
const $flyoverTitle = $flyover.querySelector(".cn-flyover-title");
const $flyoverBody = $flyover.querySelector(".cn-flyover-body");
let width = '50%';
let visible = false;
let timeHide;
const closeHandler = (e) => {
if ($flyover === e.target || $flyover.contains(e.target)) {
return;
}
clearTimeout(timeHide);
timeHide = setTimeout(() => {
flyover.hide();
}, 100);
}
const hoverHandler = (e) => {
if(e.type === "mouseenter") {
if(e.target.classList.contains("cn-nodes-name")) {
this.showNodePreview(e.target);
}
return;
}
this.hideNodePreview();
}
const displayHandler = () => {
if (visible) {
$flyover.classList.remove("cn-slide-in-right");
} else {
$flyover.classList.remove("cn-slide-out-right");
$flyover.style.width = '0px';
$flyover.style.display = "none";
}
}
const flyover = {
show: (titleHtml, bodyHtml) => {
clearTimeout(timeHide);
this.element.removeEventListener("click", closeHandler);
$flyoverTitle.innerHTML = titleHtml;
$flyoverBody.innerHTML = bodyHtml;
$flyover.style.display = "block";
$flyover.style.width = width;
if(!visible) {
$flyover.classList.add("cn-slide-in-right");
}
visible = true;
setTimeout(() => {
this.element.addEventListener("click", closeHandler);
}, 100);
},
hide: (now) => {
visible = false;
this.element.removeEventListener("click", closeHandler);
if(now) {
displayHandler();
return;
}
$flyover.classList.add("cn-slide-out-right");
}
}
$flyover.addEventListener("animationend", (e) => {
displayHandler();
});
$flyover.addEventListener("mouseenter", hoverHandler, true);
$flyover.addEventListener("mouseleave", hoverHandler, true);
$flyover.addEventListener("click", (e) => {
handleFlyoverHover(e) {
if(e.type === "mouseenter") {
if(e.target.classList.contains("cn-nodes-name")) {
const nodeName = e.target.innerText;
const nodeItem = this.nodeMap[nodeName];
if (!nodeItem) {
copyText(nodeName).then((res) => {
if (res) {
e.target.setAttribute("action", "Copied");
e.target.classList.add("action");
setTimeout(() => {
e.target.classList.remove("action");
e.target.removeAttribute("action");
}, 1000);
}
});
return;
}
this.showNodePreview(e.target);
}
return;
}
this.hideNodePreview();
}
const [x, y, w, h] = app.canvas.ds.visible_area;
const dpi = Math.max(window.devicePixelRatio ?? 1, 1);
const node = window.LiteGraph?.createNode(
nodeItem.name,
nodeItem.display_name,
{
pos: [x + (w-300) / dpi / 2, y]
handleFlyoverClick(e) {
if(e.target.classList.contains("cn-nodes-name")) {
const nodeName = e.target.innerText;
const nodeItem = this.nodeMap[nodeName];
if (!nodeItem) {
copyText(nodeName).then((res) => {
if (res) {
e.target.setAttribute("action", "Copied");
e.target.classList.add("action");
setTimeout(() => {
e.target.classList.remove("action");
e.target.removeAttribute("action");
}, 1000);
}
);
if (node) {
app.graph.add(node);
e.target.setAttribute("action", "Added to Workflow");
e.target.classList.add("action");
setTimeout(() => {
e.target.classList.remove("action");
e.target.removeAttribute("action");
}, 1000);
}
});
return;
}
if(e.target.classList.contains("cn-nodes-pack")) {
const hash = e.target.getAttribute("hash");
const rowItem = this.grid.getRowItemBy("hash", hash);
//console.log(rowItem);
this.grid.scrollToRow(rowItem);
this.addHighlight(rowItem);
return;
}
if(e.target.classList.contains("cn-flyover-close")) {
flyover.hide();
return;
}
});
return flyover;
const [x, y, w, h] = app.canvas.ds.visible_area;
const dpi = Math.max(window.devicePixelRatio ?? 1, 1);
const node = window.LiteGraph?.createNode(
nodeItem.name,
nodeItem.display_name,
{
pos: [x + (w-300) / dpi / 2, y]
}
);
if (node) {
app.graph.add(node);
e.target.setAttribute("action", "Added to Workflow");
e.target.classList.add("action");
setTimeout(() => {
e.target.classList.remove("action");
e.target.removeAttribute("action");
}, 1000);
}
return;
}
if(e.target.classList.contains("cn-nodes-pack")) {
const hash = e.target.getAttribute("hash");
const rowItem = this.grid.getRowItemBy("hash", hash);
//console.log(rowItem);
this.grid.scrollToRow(rowItem);
this.addHighlight(rowItem);
return;
}
}
showNodes(d) {
@ -1872,7 +1806,10 @@ export class CustomNodesManager {
for(let k in allUsedNodes) {
var item;
if(allUsedNodes[k].properties.cnr_id) {
item = this.custom_nodes[allUsedNodes[k].properties.cnr_id];
const foundPackage = findPackageByCnrId(allUsedNodes[k].properties.cnr_id, this.custom_nodes, false);
if (foundPackage) {
item = foundPackage.pack;
}
}
else if(allUsedNodes[k].properties.aux_id) {
item = aux_id_to_pack[allUsedNodes[k].properties.aux_id];
@ -1919,6 +1856,48 @@ export class CustomNodesManager {
return hashMap;
}
async getUsedInAnyWorkflow() {
this.showStatus(`Loading workflow usage analysis ...`);
const result = await analyzeWorkflowUsage(this.custom_nodes);
if (!result.success) {
this.showError(`Failed to get workflow data: ${result.error}`);
return {};
}
const hashMap = {};
// Convert usage map keys to hash map
result.usageMap.forEach((count, packageKey) => {
const pack = this.custom_nodes[packageKey];
if (pack && pack.hash) {
hashMap[pack.hash] = true;
}
});
return hashMap;
}
async getNotUsedInAnyWorkflow() {
this.showStatus(`Loading workflow usage analysis ...`);
// Get the used packages first using common utility
const usedHashMap = await this.getUsedInAnyWorkflow();
const notUsedHashMap = {};
// Find all installed packages that are NOT in the used list
for(let k in this.custom_nodes) {
let nodepack = this.custom_nodes[k];
// Only consider installed packages
if (nodepack.state !== "not-installed" && !usedHashMap[nodepack.hash]) {
notUsedHashMap[nodepack.hash] = true;
}
}
return notUsedHashMap;
}
async loadData(show_mode = ShowMode.NORMAL) {
const isElectron = 'electronAPI' in window;
@ -1988,6 +1967,10 @@ export class CustomNodesManager {
hashMap = await this.getFavorites();
} else if(this.show_mode == ShowMode.IN_WORKFLOW) {
hashMap = await this.getNodepackInWorkflow();
} else if(this.show_mode == ShowMode.USED_IN_ANY_WORKFLOW) {
hashMap = await this.getUsedInAnyWorkflow();
} else if(this.show_mode == ShowMode.NOT_USED_IN_ANY_WORKFLOW) {
hashMap = await this.getNotUsedInAnyWorkflow();
}
filterItem.hashMap = hashMap;

View File

@ -3,7 +3,7 @@ import { $el } from "../../scripts/ui.js";
import {
manager_instance, rebootAPI,
fetchData, md5, icons, show_message, customAlert, infoToast, showTerminal,
storeColumnWidth, restoreColumnWidth, loadCss, handle403Response
storeColumnWidth, restoreColumnWidth, loadCss, formatSize, sizeToBytes
} from "./common.js";
import { api } from "../../scripts/api.js";
@ -364,7 +364,7 @@ export class ModelManager {
width: 100,
formatter: (size) => {
if (typeof size === "number") {
return this.formatSize(size);
return formatSize(size);
}
return size;
}
@ -587,7 +587,7 @@ export class ModelManager {
models.forEach((item, i) => {
const { type, base, name, reference, installed } = item;
item.originalData = JSON.parse(JSON.stringify(item));
item.size = this.sizeToBytes(item.size);
item.size = sizeToBytes(item.size);
item.hash = md5(name + reference);
item.id = i + 1;
@ -664,7 +664,6 @@ export class ModelManager {
const { models } = res.data;
this.modelList = this.getModelList(models);
// console.log("models", this.modelList);
this.updateFilter();
@ -676,56 +675,6 @@ export class ModelManager {
// ===========================================================================================
formatSize(v) {
const base = 1000;
const units = ['', 'K', 'M', 'G', 'T', 'P'];
const space = '';
const postfix = 'B';
if (v <= 0) {
return `0${space}${postfix}`;
}
for (let i = 0, l = units.length; i < l; i++) {
const min = Math.pow(base, i);
const max = Math.pow(base, i + 1);
if (v > min && v <= max) {
const unit = units[i];
if (unit) {
const n = v / min;
const nl = n.toString().split('.')[0].length;
const fl = Math.max(3 - nl, 1);
v = n.toFixed(fl);
}
v = v + space + unit + postfix;
break;
}
}
return v;
}
// for size sort
sizeToBytes(v) {
if (typeof v === "number") {
return v;
}
if (typeof v === "string") {
const n = parseFloat(v);
const unit = v.replace(/[0-9.B]+/g, "").trim().toUpperCase();
if (unit === "K") {
return n * 1000;
}
if (unit === "M") {
return n * 1000 * 1000;
}
if (unit === "G") {
return n * 1000 * 1000 * 1000;
}
if (unit === "T") {
return n * 1000 * 1000 * 1000 * 1000;
}
}
return v;
}
showSelection(msg) {
this.element.querySelector(".cmm-manager-selection").innerHTML = msg;
}

699
js/node-usage-analyzer.css Normal file
View File

@ -0,0 +1,699 @@
.nu-manager {
--grid-font: -apple-system, BlinkMacSystemFont, "Segue UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
z-index: 1099;
width: 80%;
height: 80%;
display: flex;
flex-direction: column;
gap: 10px;
color: var(--fg-color);
font-family: arial, sans-serif;
text-underline-offset: 3px;
outline: none;
}
.nu-manager .nu-flex-auto {
flex: auto;
}
.nu-manager button {
font-size: 16px;
color: var(--input-text);
background-color: var(--comfy-input-bg);
border-radius: 8px;
border-color: var(--border-color);
border-style: solid;
margin: 0;
padding: 4px 8px;
min-width: 100px;
}
.nu-manager button:disabled,
.nu-manager input:disabled,
.nu-manager select:disabled {
color: gray;
}
.nu-manager button:disabled {
background-color: var(--comfy-input-bg);
}
.nu-manager .nu-manager-restart {
display: none;
background-color: #500000;
color: white;
}
.nu-manager .nu-manager-stop {
display: none;
background-color: #500000;
color: white;
}
.nu-manager .nu-manager-back {
align-items: center;
justify-content: center;
}
.arrow-icon {
height: 1em;
width: 1em;
margin-right: 5px;
transform: translateY(2px);
}
.cn-icon {
display: block;
width: 16px;
height: 16px;
}
.cn-icon svg {
display: block;
margin: 0;
pointer-events: none;
}
.nu-manager-header {
display: flex;
flex-wrap: wrap;
gap: 5px;
align-items: center;
padding: 0 5px;
}
.nu-manager-header label {
display: flex;
gap: 5px;
align-items: center;
}
.nu-manager-filter {
height: 28px;
line-height: 28px;
}
.nu-manager-keywords {
height: 28px;
line-height: 28px;
padding: 0 5px 0 26px;
background-size: 16px;
background-position: 5px center;
background-repeat: no-repeat;
background-image: url("data:image/svg+xml;charset=utf8,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20width%3D%22100%25%22%20height%3D%22100%25%22%20pointer-events%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill%3D%22none%22%20stroke%3D%22%23888%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20stroke-width%3D%222%22%20d%3D%22m21%2021-4.486-4.494M19%2010.5a8.5%208.5%200%201%201-17%200%208.5%208.5%200%200%201%2017%200%22%2F%3E%3C%2Fsvg%3E");
}
.nu-manager-status {
padding-left: 10px;
}
.nu-manager-grid {
flex: auto;
border: 1px solid var(--border-color);
overflow: hidden;
position: relative;
}
.nu-manager-selection {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.nu-manager-message {
position: relative;
}
.nu-manager-footer {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.nu-manager-grid .tg-turbogrid {
font-family: var(--grid-font);
font-size: 15px;
background: var(--bg-color);
}
.nu-manager-grid .tg-turbogrid .tg-highlight::after {
position: absolute;
top: 0;
left: 0;
content: "";
display: block;
width: 100%;
height: 100%;
box-sizing: border-box;
background-color: #80bdff11;
pointer-events: none;
}
.nu-manager-grid .nu-pack-name a {
color: skyblue;
text-decoration: none;
word-break: break-word;
}
.nu-manager-grid .cn-pack-desc a {
color: #5555FF;
font-weight: bold;
text-decoration: none;
}
.nu-manager-grid .tg-cell a:hover {
text-decoration: underline;
}
.nu-manager-grid .cn-pack-version {
line-height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
height: 100%;
gap: 5px;
}
.nu-manager-grid .cn-pack-nodes {
line-height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
gap: 5px;
cursor: pointer;
height: 100%;
}
.nu-manager-grid .cn-pack-nodes:hover {
text-decoration: underline;
}
.nu-manager-grid .cn-pack-conflicts {
color: orange;
}
.cn-popover {
position: fixed;
z-index: 10000;
padding: 20px;
color: #1e1e1e;
filter: drop-shadow(1px 5px 5px rgb(0 0 0 / 30%));
overflow: hidden;
}
.cn-flyover {
position: absolute;
top: 0;
right: 0;
z-index: 1000;
display: none;
width: 50%;
height: 100%;
background-color: var(--comfy-menu-bg);
animation-duration: 0.2s;
animation-fill-mode: both;
flex-direction: column;
}
.cn-flyover::before {
position: absolute;
top: 0;
content: "";
z-index: 10;
display: block;
width: 10px;
height: 100%;
pointer-events: none;
left: -10px;
background-image: linear-gradient(to left, rgb(0 0 0 / 20%), rgb(0 0 0 / 0%));
}
.cn-flyover-header {
height: 45px;
display: flex;
align-items: center;
gap: 5px;
border-bottom: 1px solid var(--border-color);
}
.cn-flyover-close {
display: flex;
align-items: center;
padding: 0 10px;
justify-content: center;
cursor: pointer;
opacity: 0.8;
height: 100%;
}
.cn-flyover-close:hover {
opacity: 1;
}
.cn-flyover-close svg {
display: block;
margin: 0;
pointer-events: none;
width: 20px;
height: 20px;
}
.cn-flyover-title {
display: flex;
align-items: center;
font-weight: bold;
gap: 10px;
flex: auto;
}
.cn-flyover-body {
height: calc(100% - 45px);
overflow-y: auto;
position: relative;
background-color: var(--comfy-menu-secondary-bg);
}
@keyframes cn-slide-in-right {
from {
visibility: visible;
transform: translate3d(100%, 0, 0);
}
to {
transform: translate3d(0, 0, 0);
}
}
.cn-slide-in-right {
animation-name: cn-slide-in-right;
}
@keyframes cn-slide-out-right {
from {
transform: translate3d(0, 0, 0);
}
to {
visibility: hidden;
transform: translate3d(100%, 0, 0);
}
}
.cn-slide-out-right {
animation-name: cn-slide-out-right;
}
.cn-nodes-list {
width: 100%;
}
.cn-nodes-row {
display: flex;
align-items: center;
gap: 10px;
}
.cn-nodes-row:nth-child(odd) {
background-color: rgb(0 0 0 / 5%);
}
.cn-nodes-row:hover {
background-color: rgb(0 0 0 / 10%);
}
.cn-nodes-sn {
text-align: right;
min-width: 35px;
color: var(--drag-text);
flex-shrink: 0;
font-size: 12px;
padding: 8px 5px;
}
.cn-nodes-name {
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
position: relative;
padding: 8px 5px;
}
.cn-nodes-name::after {
content: attr(action);
position: absolute;
pointer-events: none;
top: 50%;
left: 100%;
transform: translate(5px, -50%);
font-size: 12px;
color: var(--drag-text);
background-color: var(--comfy-input-bg);
border-radius: 10px;
border: 1px solid var(--border-color);
padding: 3px 8px;
display: none;
}
.cn-nodes-name.action::after {
display: block;
}
.cn-nodes-name:hover {
text-decoration: underline;
}
.cn-nodes-conflict .cn-nodes-name,
.cn-nodes-conflict .cn-icon {
color: orange;
}
.cn-conflicts-list {
display: flex;
flex-wrap: wrap;
gap: 5px;
align-items: center;
padding: 5px 0;
}
.cn-conflicts-list b {
font-weight: normal;
color: var(--descrip-text);
}
.cn-nodes-pack {
cursor: pointer;
color: skyblue;
}
.cn-nodes-pack:hover {
text-decoration: underline;
}
.cn-pack-badge {
font-size: 12px;
font-weight: normal;
background-color: var(--comfy-input-bg);
border-radius: 10px;
border: 1px solid var(--border-color);
padding: 3px 8px;
color: var(--error-text);
}
.cn-preview {
min-width: 300px;
max-width: 500px;
min-height: 120px;
overflow: hidden;
font-size: 12px;
pointer-events: none;
padding: 12px;
color: var(--fg-color);
}
.cn-preview-header {
display: flex;
gap: 8px;
align-items: center;
border-bottom: 1px solid var(--comfy-input-bg);
padding: 5px 10px;
}
.cn-preview-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: grey;
position: relative;
filter: drop-shadow(1px 2px 3px rgb(0 0 0 / 30%));
}
.cn-preview-dot.cn-preview-optional::after {
content: "";
position: absolute;
pointer-events: none;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: var(--comfy-input-bg);
border-radius: 50%;
width: 3px;
height: 3px;
}
.cn-preview-dot.cn-preview-grid {
border-radius: 0;
}
.cn-preview-dot.cn-preview-grid::before {
content: '';
position: absolute;
border-left: 1px solid var(--comfy-input-bg);
border-right: 1px solid var(--comfy-input-bg);
width: 4px;
height: 100%;
left: 2px;
top: 0;
z-index: 1;
}
.cn-preview-dot.cn-preview-grid::after {
content: '';
position: absolute;
border-top: 1px solid var(--comfy-input-bg);
border-bottom: 1px solid var(--comfy-input-bg);
width: 100%;
height: 4px;
left: 0;
top: 2px;
z-index: 1;
}
.cn-preview-name {
flex: auto;
font-size: 14px;
}
.cn-preview-io {
display: flex;
justify-content: space-between;
padding: 10px 10px;
}
.cn-preview-column > div {
display: flex;
gap: 10px;
align-items: center;
height: 18px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.cn-preview-input {
justify-content: flex-start;
}
.cn-preview-output {
justify-content: flex-end;
}
.cn-preview-list {
display: flex;
flex-direction: column;
gap: 3px;
padding: 0 10px 10px 10px;
}
.cn-preview-switch {
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
background: var(--bg-color);
border: 2px solid var(--border-color);
border-radius: 10px;
text-wrap: nowrap;
padding: 2px 20px;
gap: 10px;
}
.cn-preview-switch::before,
.cn-preview-switch::after {
position: absolute;
pointer-events: none;
top: 50%;
transform: translate(0, -50%);
color: var(--fg-color);
opacity: 0.8;
}
.cn-preview-switch::before {
content: "◀";
left: 5px;
}
.cn-preview-switch::after {
content: "▶";
right: 5px;
}
.cn-preview-value {
color: var(--descrip-text);
}
.cn-preview-string {
min-height: 30px;
max-height: 300px;
background: var(--bg-color);
color: var(--descrip-text);
border-radius: 3px;
padding: 3px 5px;
overflow-y: auto;
overflow-x: hidden;
}
.cn-preview-description {
margin: 0px 10px 10px 10px;
padding: 6px;
background: var(--border-color);
color: var(--descrip-text);
border-radius: 5px;
font-style: italic;
word-break: break-word;
}
.cn-tag-list {
display: flex;
flex-wrap: wrap;
gap: 5px;
align-items: center;
margin-bottom: 5px;
}
.cn-tag-list > div {
background-color: var(--border-color);
border-radius: 5px;
padding: 0 5px;
}
.cn-install-buttons {
display: flex;
flex-direction: column;
gap: 3px;
padding: 3px;
align-items: center;
justify-content: center;
height: 100%;
}
.cn-selected-buttons {
display: flex;
gap: 5px;
align-items: center;
padding-right: 20px;
}
.nu-manager .cn-btn-enable {
background-color: #333399;
color: white;
}
.nu-manager .cn-btn-disable {
background-color: #442277;
color: white;
}
.nu-manager .cn-btn-update {
background-color: #1155AA;
color: white;
}
.nu-manager .cn-btn-try-update {
background-color: Gray;
color: white;
}
.nu-manager .cn-btn-try-fix {
background-color: #6495ED;
color: white;
}
.nu-manager .cn-btn-import-failed {
background-color: #AA1111;
font-size: 10px;
font-weight: bold;
color: white;
}
.nu-manager .cn-btn-install {
background-color: black;
color: white;
}
.nu-manager .cn-btn-try-install {
background-color: Gray;
color: white;
}
.nu-manager .cn-btn-uninstall {
background-color: #993333;
color: white;
}
.nu-manager .cn-btn-reinstall {
background-color: #993333;
color: white;
}
.nu-manager .cn-btn-switch {
background-color: #448833;
color: white;
}
@keyframes nu-btn-loading-bg {
0% {
left: 0;
}
100% {
left: -105px;
}
}
.nu-manager button.nu-btn-loading {
position: relative;
overflow: hidden;
border-color: rgb(0 119 207 / 80%);
background-color: var(--comfy-input-bg);
}
.nu-manager button.nu-btn-loading::after {
position: absolute;
top: 0;
left: 0;
content: "";
width: 500px;
height: 100%;
background-image: repeating-linear-gradient(
-45deg,
rgb(0 119 207 / 30%),
rgb(0 119 207 / 30%) 10px,
transparent 10px,
transparent 15px
);
animation: nu-btn-loading-bg 2s linear infinite;
}
.nu-manager-light .nu-pack-name a {
color: blue;
}
.nu-manager-light .cm-warn-note {
background-color: #ccc !important;
}
.nu-manager-light .cn-btn-install {
background-color: #333;
}

742
js/node-usage-analyzer.js Normal file
View File

@ -0,0 +1,742 @@
import { app } from "../../scripts/app.js";
import { $el } from "../../scripts/ui.js";
import {
manager_instance,
fetchData, md5, show_message, customAlert, infoToast, showTerminal,
storeColumnWidth, restoreColumnWidth, loadCss, uninstallNodes,
analyzeWorkflowUsage, sizeToBytes, createFlyover, createUIStateManager
} from "./common.js";
import { api } from "../../scripts/api.js";
// https://cenfun.github.io/turbogrid/api.html
import TG from "./turbogrid.esm.js";
loadCss("./node-usage-analyzer.css");
const gridId = "model";
const pageHtml = `
<div class="nu-manager-header">
<div class="nu-manager-status"></div>
<input type="text" class="nu-manager-keywords" placeholder="Filter keywords..." />
<div class="nu-flex-auto"></div>
</div>
<div class="nu-manager-grid"></div>
<div class="nu-manager-selection"></div>
<div class="nu-manager-message"></div>
<div class="nu-manager-footer">
<button class="nu-manager-back">
<svg class="arrow-icon" width="14" height="14" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 8H18M2 8L8 2M2 8L8 14" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Back
</button>
<button class="nu-manager-refresh">Refresh</button>
<button class="nu-manager-stop">Stop</button>
<div class="nu-flex-auto"></div>
</div>
`;
export class NodeUsageAnalyzer {
static instance = null;
static SortMode = {
BY_PACKAGE: 'by_package'
};
constructor(app, manager_dialog) {
this.app = app;
this.manager_dialog = manager_dialog;
this.id = "nu-manager";
this.filter = '';
this.type = '';
this.base = '';
this.keywords = '';
this.init();
// Initialize shared UI state manager
this.ui = createUIStateManager(this.element, {
selection: ".nu-manager-selection",
message: ".nu-manager-message",
status: ".nu-manager-status",
refresh: ".nu-manager-refresh",
stop: ".nu-manager-stop"
});
api.addEventListener("cm-queue-status", this.onQueueStatus);
}
init() {
this.element = $el("div", {
parent: document.body,
className: "comfy-modal nu-manager"
});
this.element.innerHTML = pageHtml;
this.bindEvents();
this.initGrid();
}
bindEvents() {
const eventsMap = {
".nu-manager-selection": {
click: (e) => {
const target = e.target;
const mode = target.getAttribute("mode");
if (mode === "install") {
this.installModels(this.selectedModels, target);
} else if (mode === "uninstall") {
this.uninstallModels(this.selectedModels, target);
}
}
},
".nu-manager-refresh": {
click: () => {
app.refreshComboInNodes();
}
},
".nu-manager-stop": {
click: () => {
api.fetchApi('/manager/queue/reset');
infoToast('Cancel', 'Remaining tasks will stop after completing the current task.');
}
},
".nu-manager-back": {
click: (e) => {
this.close()
manager_instance.show();
}
}
};
Object.keys(eventsMap).forEach(selector => {
const target = this.element.querySelector(selector);
if (target) {
const events = eventsMap[selector];
if (events) {
Object.keys(events).forEach(type => {
target.addEventListener(type, events[type]);
});
}
}
});
}
// ===========================================================================================
initGrid() {
const container = this.element.querySelector(".nu-manager-grid");
const grid = new TG.Grid(container);
this.grid = grid;
this.flyover = createFlyover(container, { context: this });
grid.bind('onUpdated', (e, d) => {
this.ui.showStatus(`${grid.viewRows.length.toLocaleString()} installed packages`);
});
grid.bind('onSelectChanged', (e, changes) => {
this.renderSelected();
});
grid.bind("onColumnWidthChanged", (e, columnItem) => {
storeColumnWidth(gridId, columnItem)
});
grid.bind('onClick', (e, d) => {
const { rowItem } = d;
const target = d.e.target;
const mode = target.getAttribute("mode");
if (mode === "install") {
this.installModels([rowItem], target);
return;
}
if (mode === "uninstall") {
this.uninstallModels([rowItem], target);
return;
}
// Handle click on usage count
if (d.columnItem.id === "used_in_count" && rowItem.used_in_count > 0) {
this.showUsageDetails(rowItem);
return;
}
});
grid.setOption({
theme: 'dark',
selectVisible: true,
selectMultiple: true,
selectAllVisible: true,
textSelectable: true,
scrollbarRound: true,
frozenColumn: 1,
rowNotFound: "No Results",
rowHeight: 40,
bindWindowResize: true,
bindContainerResize: true,
cellResizeObserver: (rowItem, columnItem) => {
const autoHeightColumns = ['name', 'description'];
return autoHeightColumns.includes(columnItem.id)
}
});
}
renderGrid() {
// update theme
const colorPalette = this.app.ui.settings.settingsValues['Comfy.ColorPalette'];
Array.from(this.element.classList).forEach(cn => {
if (cn.startsWith("nu-manager-")) {
this.element.classList.remove(cn);
}
});
this.element.classList.add(`nu-manager-${colorPalette}`);
const options = {
theme: colorPalette === "light" ? "" : "dark"
};
const rows = this.modelList || [];
const columns = [{
id: 'title',
name: 'Title',
width: 200,
minWidth: 100,
maxWidth: 500,
classMap: 'nu-pack-name',
formatter: function (name, rowItem, columnItem, cellNode) {
return `<a href=${rowItem.reference} target="_blank"><b>${name}</b></a>`;
}
}, {
id: 'used_in_count',
name: 'Used in',
width: 100,
formatter: function (usedCount, rowItem, columnItem) {
if (!usedCount || usedCount === 0) {
return '0';
}
const plural = usedCount > 1 ? 's' : '';
return `<div class="cn-pack-nodes" style="cursor: pointer;">${usedCount} workflow${plural}</div>`;
}
}, {
id: 'action',
name: 'Action',
width: 160,
minWidth: 140,
maxWidth: 200,
sortable: false,
align: 'center',
formatter: function (action, rowItem, columnItem) {
// Only show uninstall button for installed packages
if (rowItem.originalData && rowItem.originalData.state && rowItem.originalData.state !== "not-installed") {
return `<div class="cn-install-buttons"><button class="nu-btn-uninstall" mode="uninstall">Uninstall</button></div>`;
}
return '';
}
}];
restoreColumnWidth(gridId, columns);
this.grid.setData({
options,
rows,
columns
});
this.grid.render();
}
updateGrid() {
if (this.grid) {
this.grid.update();
}
}
showUsageDetails(rowItem) {
const workflowList = rowItem.workflowDetails;
if (!workflowList || workflowList.length === 0) {
return;
}
let titleHtml = `<div class="cn-nodes-pack">${rowItem.title}</div>`;
const list = [];
list.push(`<div class="cn-nodes-list">`);
workflowList.forEach((workflow, i) => {
list.push(`<div class="cn-nodes-row">`);
list.push(`<div class="cn-nodes-sn">${i + 1}</div>`);
list.push(`<div class="cn-nodes-name">${workflow.filename}</div>`);
list.push(`<div class="cn-nodes-details">${workflow.nodeCount} node${workflow.nodeCount > 1 ? 's' : ''}</div>`);
list.push(`</div>`);
});
list.push("</div>");
const bodyHtml = list.join("");
this.flyover.show(titleHtml, bodyHtml);
}
renderSelected() {
const selectedList = this.grid.getSelectedRows();
if (!selectedList.length) {
this.ui.showSelection("");
return;
}
const installedSelected = selectedList.filter(item =>
item.originalData && item.originalData.state && item.originalData.state !== "not-installed"
);
if (installedSelected.length === 0) {
this.ui.showSelection(`<span>Selected <b>${selectedList.length}</b> packages (none can be uninstalled)</span>`);
return;
}
this.selectedModels = installedSelected;
this.ui.showSelection(`
<div class="nu-selected-buttons">
<span>Selected <b>${installedSelected.length}</b> installed packages</span>
<button class="nu-btn-uninstall" mode="uninstall">Uninstall Selected</button>
</div>
`);
}
// ===========================================================================================
async installModels(list, btn) {
let stats = await api.fetchApi('/manager/queue/status');
stats = await stats.json();
if (stats.is_processing) {
customAlert(`[ComfyUI-Manager] There are already tasks in progress. Please try again after it is completed. (${stats.done_count}/${stats.total_count})`);
return;
}
btn.classList.add("nu-btn-loading");
this.ui.showError("");
let needRefresh = false;
let errorMsg = "";
await api.fetchApi('/manager/queue/reset');
let target_items = [];
for (const item of list) {
this.grid.scrollRowIntoView(item);
target_items.push(item);
this.ui.showStatus(`Install ${item.name} ...`);
const data = item.originalData;
data.ui_id = item.hash;
const res = await api.fetchApi(`/manager/queue/install_model`, {
method: 'POST',
body: JSON.stringify(data)
});
if (res.status != 200) {
errorMsg = `'${item.name}': `;
if (res.status == 403) {
errorMsg += `This action is not allowed with this security level configuration.\n`;
} else {
errorMsg += await res.text() + '\n';
}
break;
}
}
this.install_context = { btn: btn, targets: target_items };
if (errorMsg) {
this.ui.showError(errorMsg);
show_message("[Installation Errors]\n" + errorMsg);
// reset
for (let k in target_items) {
const item = target_items[k];
this.grid.updateCell(item, "installed");
}
}
else {
await api.fetchApi('/manager/queue/start');
this.ui.showStop();
showTerminal();
}
}
async uninstallModels(list, btn) {
btn.classList.add("nu-btn-loading");
this.ui.showError("");
const result = await uninstallNodes(list, {
title: list.length === 1 ? list[0].title || list[0].name : `${list.length} custom nodes`,
channel: 'default',
mode: 'default',
onProgress: (msg) => {
this.showStatus(msg);
},
onError: (errorMsg) => {
this.showError(errorMsg);
},
onSuccess: (targets) => {
this.showStatus(`Uninstalled ${targets.length} custom node(s) successfully`);
this.showMessage(`To apply the uninstalled custom nodes, please restart ComfyUI and refresh browser.`, "red");
// Update the grid to reflect changes
for (let item of targets) {
if (item.originalData) {
item.originalData.state = "not-installed";
}
this.grid.updateRow(item);
}
}
});
if (result.success) {
this.showStop();
}
btn.classList.remove("nu-btn-loading");
}
async onQueueStatus(event) {
let self = NodeUsageAnalyzer.instance;
if (event.detail.status == 'in_progress' && (event.detail.ui_target == 'model_manager' || event.detail.ui_target == 'nodepack_manager')) {
const hash = event.detail.target;
const item = self.grid.getRowItemBy("hash", hash);
if (item) {
item.refresh = true;
self.grid.setRowSelected(item, false);
item.selectable = false;
self.grid.updateRow(item);
}
}
else if (event.detail.status == 'done') {
self.hideStop();
self.onQueueCompleted(event.detail);
}
}
async onQueueCompleted(info) {
let result = info.model_result || info.nodepack_result;
if (!result || result.length == 0) {
return;
}
let self = NodeUsageAnalyzer.instance;
if (!self.install_context) {
return;
}
let btn = self.install_context.btn;
self.hideLoading();
btn.classList.remove("nu-btn-loading");
let errorMsg = "";
for (let hash in result) {
let v = result[hash];
if (v != 'success' && v != 'skip')
errorMsg += v + '\n';
}
for (let k in self.install_context.targets) {
let item = self.install_context.targets[k];
if (info.model_result) {
self.grid.updateCell(item, "installed");
} else if (info.nodepack_result) {
// Handle uninstall completion
if (item.originalData) {
item.originalData.state = "not-installed";
}
self.grid.updateRow(item);
}
}
if (errorMsg) {
self.showError(errorMsg);
show_message("Operation Error:\n" + errorMsg);
} else {
if (info.model_result) {
self.showStatus(`Install ${Object.keys(result).length} models successfully`);
self.showRefresh();
self.showMessage(`To apply the installed model, please click the 'Refresh' button.`, "red");
} else if (info.nodepack_result) {
self.showStatus(`Uninstall ${Object.keys(result).length} custom node(s) successfully`);
self.showMessage(`To apply the uninstalled custom nodes, please restart ComfyUI and refresh browser.`, "red");
}
}
infoToast('Tasks done', `[ComfyUI-Manager] All tasks in the queue have been completed.\n${info.done_count}/${info.total_count}`);
self.install_context = undefined;
}
getModelList(models) {
const typeMap = new Map();
const baseMap = new Map();
models.forEach((item, i) => {
const { type, base, name, reference, installed } = item;
// CRITICAL FIX: Do NOT overwrite originalData - it contains the needed state field!
item.size = sizeToBytes(item.size);
item.hash = md5(name + reference);
if (installed === "True") {
item.selectable = false;
}
typeMap.set(type, type);
baseMap.set(base, base);
});
const typeList = [];
typeMap.forEach(type => {
typeList.push({
label: type,
value: type
});
});
typeList.sort((a, b) => {
const au = a.label.toUpperCase();
const bu = b.label.toUpperCase();
if (au !== bu) {
return au > bu ? 1 : -1;
}
return 0;
});
this.typeList = [{
label: "All",
value: ""
}].concat(typeList);
const baseList = [];
baseMap.forEach(base => {
baseList.push({
label: base,
value: base
});
});
baseList.sort((a, b) => {
const au = a.label.toUpperCase();
const bu = b.label.toUpperCase();
if (au !== bu) {
return au > bu ? 1 : -1;
}
return 0;
});
this.baseList = [{
label: "All",
value: ""
}].concat(baseList);
return models;
}
// ===========================================================================================
async loadData() {
this.showLoading();
this.showStatus(`Analyzing node usage ...`);
const mode = manager_instance.datasrc_combo.value;
const nodeListRes = await fetchData(`/customnode/getlist?mode=${mode}&skip_update=true`);
if (nodeListRes.error) {
this.showError("Failed to get custom node list.");
this.hideLoading();
return;
}
const { channel, node_packs } = nodeListRes.data;
delete node_packs['comfyui-manager'];
this.installed_custom_node_packs = node_packs;
// Use the consolidated workflow analysis utility
const result = await analyzeWorkflowUsage(node_packs);
if (!result.success) {
if (result.error.toString().includes('204')) {
this.showMessage("No workflows were found for analysis.");
} else {
this.showError(result.error);
this.hideLoading();
return;
}
}
// Transform node_packs into models format - ONLY INSTALLED PACKAGES
const models = [];
Object.keys(node_packs).forEach((packKey, index) => {
const pack = node_packs[packKey];
// Only include installed packages (filter out "not-installed" packages)
if (pack.state === "not-installed") {
return; // Skip non-installed packages
}
const usedCount = result.usageMap?.get(packKey) || 0;
const workflowDetails = result.workflowDetailsMap?.get(packKey) || [];
models.push({
title: pack.title || packKey,
reference: pack.reference || pack.files?.[0] || '#',
used_in_count: usedCount,
workflowDetails: workflowDetails,
name: packKey,
originalData: pack
});
});
// Sort by usage count (descending) then by title
models.sort((a, b) => {
if (b.used_in_count !== a.used_in_count) {
return b.used_in_count - a.used_in_count;
}
return a.title.localeCompare(b.title);
});
this.modelList = this.getModelList(models);
this.renderGrid();
this.hideLoading();
}
// ===========================================================================================
showSelection(msg) {
this.element.querySelector(".nu-manager-selection").innerHTML = msg;
}
showError(err) {
this.showMessage(err, "red");
}
showMessage(msg, color) {
if (color) {
msg = `<font color="${color}">${msg}</font>`;
}
this.element.querySelector(".nu-manager-message").innerHTML = msg;
}
showStatus(msg, color) {
if (color) {
msg = `<font color="${color}">${msg}</font>`;
}
this.element.querySelector(".nu-manager-status").innerHTML = msg;
}
showLoading() {
// this.setDisabled(true);
if (this.grid) {
this.grid.showLoading();
this.grid.showMask({
opacity: 0.05
});
}
}
hideLoading() {
// this.setDisabled(false);
if (this.grid) {
this.grid.hideLoading();
this.grid.hideMask();
}
}
setDisabled(disabled) {
const $close = this.element.querySelector(".nu-manager-close");
const $refresh = this.element.querySelector(".nu-manager-refresh");
const $stop = this.element.querySelector(".nu-manager-stop");
const list = [
".nu-manager-header input",
".nu-manager-header select",
".nu-manager-footer button",
".nu-manager-selection button"
].map(s => {
return Array.from(this.element.querySelectorAll(s));
})
.flat()
.filter(it => {
return it !== $close && it !== $refresh && it !== $stop;
});
list.forEach($elem => {
if (disabled) {
$elem.setAttribute("disabled", "disabled");
} else {
$elem.removeAttribute("disabled");
}
});
Array.from(this.element.querySelectorAll(".nu-btn-loading")).forEach($elem => {
$elem.classList.remove("nu-btn-loading");
});
}
showRefresh() {
this.element.querySelector(".nu-manager-refresh").style.display = "block";
}
showStop() {
this.element.querySelector(".nu-manager-stop").style.display = "block";
}
hideStop() {
this.element.querySelector(".nu-manager-stop").style.display = "none";
}
setKeywords(keywords = "") {
this.keywords = keywords;
this.element.querySelector(".nu-manager-keywords").value = keywords;
}
show(sortMode) {
this.element.style.display = "flex";
this.setKeywords("");
this.showSelection("");
this.showMessage("");
this.loadData();
}
close() {
this.element.style.display = "none";
}
}

View File

@ -250,6 +250,30 @@ paths:
type: object
additionalProperties:
$ref: '#/components/schemas/NodePackageMetadata'
/customnode/get_node_types_in_workflows:
get:
summary: List node types used by all user workflows
description: Scan through all workflows in the Comfy user directory, and return a list of all node types used in each one.
responses:
'200':
description: Successful operation
content:
application/json:
schema:
type: array
items:
type: object
properties:
workflow_file_name:
type: string
node_types:
type: array
items:
type: string
'500':
description: Error occurred
/customnode/alternatives:
get: