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 = `
+
${icons.arrowRight}
+
+
${icons.close}
+
+
` + 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 = `
-
${icons.arrowRight}
-
-
${icons.close}
-
-
` - 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: