diff --git a/glob/manager_server.py b/glob/manager_server.py
index cb3bcd92e..0719df83f 100644
--- a/glob/manager_server.py
+++ b/glob/manager_server.py
@@ -11,7 +11,10 @@
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
@@ -726,6 +729,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")
diff --git a/js/README.md b/js/README.md
index 3832a17c7..a8411a29e 100644
--- a/js/README.md
+++ b/js/README.md
@@ -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.
\ No newline at end of file
diff --git a/js/comfyui-manager.js b/js/comfyui-manager.js
index 6fc504b1f..33f1d0004 100644
--- a/js/comfyui-manager.js
+++ b/js/comfyui-manager.js
@@ -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";
@@ -910,6 +911,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", {
diff --git a/js/common.js b/js/common.js
index 71cf58ea5..5be50cc29 100644
--- a/js/common.js
+++ b/js/common.js
@@ -109,9 +109,9 @@ export async function customConfirm(message) {
let res = await
window['app'].extensionManager.dialog
.confirm({
- title: 'Confirm',
- message: message
- });
+ title: 'Confirm',
+ message: message
+ });
return res;
}
@@ -151,9 +151,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;
}
@@ -651,4 +651,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 "default channel" 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 = `
+ `
+ 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 ? `${err}` : "";
+ el.innerHTML = msg;
+ }
+ },
+
+ showMessage: (msg, color) => {
+ const el = element.querySelector(selectors.message);
+ if (el) {
+ if (color) {
+ msg = `${msg}`;
+ }
+ el.innerHTML = msg;
+ }
+ },
+
+ showStatus: (msg, color) => {
+ const el = element.querySelector(selectors.status);
+ if (el) {
+ if (color) {
+ msg = `${msg}`;
+ }
+ 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();
\ No newline at end of file
diff --git a/js/custom-nodes-manager.js b/js/custom-nodes-manager.js
index a5683a2de..1363f0380 100644
--- a/js/custom-nodes-manager.js
+++ b/js/custom-nodes-manager.js
@@ -7,7 +7,7 @@ import {
fetchData, md5, icons, show_message, customConfirm, customAlert, customPrompt,
sanitizeHTML, infoToast, showTerminal, setNeedRestart,
storeColumnWidth, restoreColumnWidth, getTimeAgo, copyText, loadCss,
- showPopover, hidePopover
+ 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 = `
- `
- 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);
+ handleFlyoverHover(e) {
+ if(e.type === "mouseenter") {
+ if(e.target.classList.contains("cn-nodes-name")) {
+ this.showNodePreview(e.target);
+ }
+ return;
}
+ this.hideNodePreview();
+ }
- const hoverHandler = (e) => {
- if(e.type === "mouseenter") {
- if(e.target.classList.contains("cn-nodes-name")) {
- this.showNodePreview(e.target);
- }
+ 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);
+ }
+ });
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");
+ 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]
}
- visible = true;
+ );
+ if (node) {
+ app.graph.add(node);
+ e.target.setAttribute("action", "Added to Workflow");
+ e.target.classList.add("action");
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");
+ e.target.classList.remove("action");
+ e.target.removeAttribute("action");
+ }, 1000);
}
+
+ return;
}
-
- $flyover.addEventListener("animationend", (e) => {
- displayHandler();
- });
-
- $flyover.addEventListener("mouseenter", hoverHandler, true);
- $flyover.addEventListener("mouseleave", hoverHandler, true);
-
- $flyover.addEventListener("click", (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);
- }
- });
- return;
- }
-
- 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);
+ 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;
+ this.grid.scrollToRow(rowItem);
+ this.addHighlight(rowItem);
+ return;
+ }
}
showNodes(d) {
@@ -1863,7 +1797,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];
@@ -1910,6 +1847,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;
@@ -1979,6 +1958,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;
diff --git a/js/model-manager.js b/js/model-manager.js
index 7811ab657..a62aca835 100644
--- a/js/model-manager.js
+++ b/js/model-manager.js
@@ -1,9 +1,9 @@
import { app } from "../../scripts/app.js";
import { $el } from "../../scripts/ui.js";
-import {
- manager_instance, rebootAPI,
+import {
+ manager_instance, rebootAPI,
fetchData, md5, icons, show_message, customAlert, infoToast, showTerminal,
- storeColumnWidth, restoreColumnWidth, loadCss
+ 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;
}
@@ -578,7 +578,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;
@@ -655,7 +655,6 @@ export class ModelManager {
const { models } = res.data;
this.modelList = this.getModelList(models);
- // console.log("models", this.modelList);
this.updateFilter();
@@ -667,56 +666,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;
}
diff --git a/js/node-usage-analyzer.css b/js/node-usage-analyzer.css
new file mode 100644
index 000000000..428b48cc8
--- /dev/null
+++ b/js/node-usage-analyzer.css
@@ -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;
+}
\ No newline at end of file
diff --git a/js/node-usage-analyzer.js b/js/node-usage-analyzer.js
new file mode 100644
index 000000000..aaf6e7414
--- /dev/null
+++ b/js/node-usage-analyzer.js
@@ -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 = `
+
+
+
+
+
+`;
+
+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 `${name}`;
+ }
+ }, {
+ 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 `${usedCount} workflow${plural}
`;
+ }
+ }, {
+ 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 ``;
+ }
+ 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 = `${rowItem.title}
`;
+
+ const list = [];
+ list.push(``);
+
+ workflowList.forEach((workflow, i) => {
+ list.push(`
`);
+ list.push(`
${i + 1}
`);
+ list.push(`
${workflow.filename}
`);
+ list.push(`
${workflow.nodeCount} node${workflow.nodeCount > 1 ? 's' : ''}
`);
+ list.push(`
`);
+ });
+
+ list.push("
");
+ 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(`Selected ${selectedList.length} packages (none can be uninstalled)`);
+ return;
+ }
+
+ this.selectedModels = installedSelected;
+
+ this.ui.showSelection(`
+
+ Selected ${installedSelected.length} installed packages
+
+
+ `);
+ }
+
+ // ===========================================================================================
+
+ 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 = `${msg}`;
+ }
+ this.element.querySelector(".nu-manager-message").innerHTML = msg;
+ }
+
+ showStatus(msg, color) {
+ if (color) {
+ msg = `${msg}`;
+ }
+ 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";
+ }
+}
\ No newline at end of file
diff --git a/openapi.yaml b/openapi.yaml
index 0446259e3..89b023de1 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -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: