mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-13 09:00:16 +00:00
feat: show missing node packs in Errors Tab with install support (#9213)
## Summary Surfaces missing node pack information in the Errors Tab, grouped by registry pack, with one-click install support via ComfyUI Manager. ## Changes - **What**: Errors Tab now groups missing nodes by their registry pack and shows a `MissingPackGroupRow` with pack name, node/pack counts, and an Install button that triggers Manager installation. A `MissingNodeCard` shows individual unresolvable nodes that have no associated pack. `useErrorGroups` was extended to resolve missing node types to their registry packs using the `/api/workflow/missing_nodes` endpoint. `executionErrorStore` was refactored to track missing node types separately from execution errors and expose them reactively. - **Breaking**: None ## Review Focus - `useErrorGroups.ts` — the new `resolveMissingNodePacks` logic fetches pack metadata and maps node types to pack IDs; edge cases around partial resolution (some nodes have a pack, some don't) produce both `MissingPackGroupRow` and `MissingNodeCard` entries - `executionErrorStore.ts` — the store now separates `missingNodeTypes` state from `errors`; the deferred-warnings path in `app.ts` now calls `setMissingNodeTypes` so the Errors Tab is populated even when a workflow loads without executing ## Screenshots (if applicable) https://github.com/user-attachments/assets/97f8d009-0cac-4739-8740-fd3333b5a85b ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9213-feat-show-missing-node-packs-in-Errors-Tab-with-install-support-3126d73d36508197bc4bf8ebfd2125c8) by [Unito](https://www.unito.io)
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { st } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import type {
|
||||
@@ -10,8 +13,13 @@ import type {
|
||||
PromptError
|
||||
} from '@/schemas/apiSchema'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
getAncestorExecutionIds,
|
||||
getParentExecutionIds
|
||||
} from '@/types/nodeIdentification'
|
||||
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import {
|
||||
executionIdToNodeLocatorId,
|
||||
forEachNode,
|
||||
@@ -19,13 +27,50 @@ import {
|
||||
getExecutionIdByNode
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
|
||||
/**
|
||||
* Store dedicated to execution error state management.
|
||||
*
|
||||
* Extracted from executionStore to separate error-related concerns
|
||||
* (state, computed properties, graph flag propagation, overlay UI)
|
||||
* from execution flow management (progress, queuing, events).
|
||||
*/
|
||||
interface MissingNodesError {
|
||||
message: string
|
||||
nodeTypes: MissingNodeType[]
|
||||
}
|
||||
|
||||
function clearAllNodeErrorFlags(rootGraph: LGraph): void {
|
||||
forEachNode(rootGraph, (node) => {
|
||||
node.has_errors = false
|
||||
if (node.inputs) {
|
||||
for (const slot of node.inputs) {
|
||||
slot.hasErrors = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function markNodeSlotErrors(node: LGraphNode, nodeError: NodeError): void {
|
||||
if (!node.inputs) return
|
||||
for (const error of nodeError.errors) {
|
||||
const slotName = error.extra_info?.input_name
|
||||
if (!slotName) continue
|
||||
const slot = node.inputs.find((s) => s.name === slotName)
|
||||
if (slot) slot.hasErrors = true
|
||||
}
|
||||
}
|
||||
|
||||
function applyNodeError(
|
||||
rootGraph: LGraph,
|
||||
executionId: NodeExecutionId,
|
||||
nodeError: NodeError
|
||||
): void {
|
||||
const node = getNodeByExecutionId(rootGraph, executionId)
|
||||
if (!node) return
|
||||
|
||||
node.has_errors = true
|
||||
markNodeSlotErrors(node, nodeError)
|
||||
|
||||
for (const parentId of getParentExecutionIds(executionId)) {
|
||||
const parentNode = getNodeByExecutionId(rootGraph, parentId)
|
||||
if (parentNode) parentNode.has_errors = true
|
||||
}
|
||||
}
|
||||
|
||||
/** Execution error state: node errors, runtime errors, prompt errors, and missing nodes. */
|
||||
export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
@@ -33,6 +78,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
const lastNodeErrors = ref<Record<NodeId, NodeError> | null>(null)
|
||||
const lastExecutionError = ref<ExecutionErrorWsMessage | null>(null)
|
||||
const lastPromptError = ref<PromptError | null>(null)
|
||||
const missingNodesError = ref<MissingNodesError | null>(null)
|
||||
|
||||
const isErrorOverlayOpen = ref(false)
|
||||
|
||||
@@ -49,6 +95,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
lastExecutionError.value = null
|
||||
lastPromptError.value = null
|
||||
lastNodeErrors.value = null
|
||||
missingNodesError.value = null
|
||||
isErrorOverlayOpen.value = false
|
||||
}
|
||||
|
||||
@@ -57,6 +104,48 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
lastPromptError.value = null
|
||||
}
|
||||
|
||||
/** Set missing node types and open the error overlay if the Errors tab is enabled. */
|
||||
function surfaceMissingNodes(types: MissingNodeType[]) {
|
||||
setMissingNodeTypes(types)
|
||||
if (useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')) {
|
||||
showErrorOverlay()
|
||||
}
|
||||
}
|
||||
|
||||
function setMissingNodeTypes(types: MissingNodeType[]) {
|
||||
if (!types.length) {
|
||||
missingNodesError.value = null
|
||||
return
|
||||
}
|
||||
const seen = new Set<string>()
|
||||
const uniqueTypes = types.filter((node) => {
|
||||
// For string entries (group nodes), deduplicate by the string itself.
|
||||
// For object entries, prefer nodeId so multiple instances of the same
|
||||
// type are kept as separate rows; fall back to type if nodeId is absent.
|
||||
const isString = typeof node === 'string'
|
||||
let key: string
|
||||
if (isString) {
|
||||
key = node
|
||||
} else if (node.nodeId != null) {
|
||||
key = String(node.nodeId)
|
||||
} else {
|
||||
key = node.type
|
||||
}
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
missingNodesError.value = {
|
||||
message: isCloud
|
||||
? st(
|
||||
'rightSidePanel.missingNodePacks.unsupportedTitle',
|
||||
'Unsupported Node Packs'
|
||||
)
|
||||
: st('rightSidePanel.missingNodePacks.title', 'Missing Node Packs'),
|
||||
nodeTypes: uniqueTypes
|
||||
}
|
||||
}
|
||||
|
||||
const lastExecutionErrorNodeLocatorId = computed(() => {
|
||||
const err = lastExecutionError.value
|
||||
if (!err) return null
|
||||
@@ -81,9 +170,16 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
() => !!lastNodeErrors.value && Object.keys(lastNodeErrors.value).length > 0
|
||||
)
|
||||
|
||||
/** Whether any error (node validation, runtime execution, or prompt-level) is present */
|
||||
/** Whether any missing node types are present in the current workflow */
|
||||
const hasMissingNodes = computed(() => !!missingNodesError.value)
|
||||
|
||||
/** Whether any error (node validation, runtime execution, prompt-level, or missing nodes) is present */
|
||||
const hasAnyError = computed(
|
||||
() => hasExecutionError.value || hasPromptError.value || hasNodeError.value
|
||||
() =>
|
||||
hasExecutionError.value ||
|
||||
hasPromptError.value ||
|
||||
hasNodeError.value ||
|
||||
hasMissingNodes.value
|
||||
)
|
||||
|
||||
const allErrorExecutionIds = computed<string[]>(() => {
|
||||
@@ -116,13 +212,19 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
/** Count of runtime execution errors (0 or 1) */
|
||||
const executionErrorCount = computed(() => (lastExecutionError.value ? 1 : 0))
|
||||
|
||||
/** Count of missing node errors (0 or 1) */
|
||||
const missingNodeCount = computed(() => (missingNodesError.value ? 1 : 0))
|
||||
|
||||
/** Total count of all individual errors */
|
||||
const totalErrorCount = computed(
|
||||
() =>
|
||||
promptErrorCount.value + nodeErrorCount.value + executionErrorCount.value
|
||||
promptErrorCount.value +
|
||||
nodeErrorCount.value +
|
||||
executionErrorCount.value +
|
||||
missingNodeCount.value
|
||||
)
|
||||
|
||||
/** Pre-computed Set of graph node IDs (as strings) that have errors in the current graph scope. */
|
||||
/** Graph node IDs (as strings) that have errors in the current graph scope. */
|
||||
const activeGraphErrorNodeIds = computed<Set<string>>(() => {
|
||||
const ids = new Set<string>()
|
||||
if (!app.rootGraph) return ids
|
||||
@@ -150,6 +252,44 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
return ids
|
||||
})
|
||||
|
||||
/**
|
||||
* Set of all execution ID prefixes derived from missing node execution IDs,
|
||||
* including the missing nodes themselves.
|
||||
*
|
||||
* Example: missing node at "65:70:63" → Set { "65", "65:70", "65:70:63" }
|
||||
*/
|
||||
const missingAncestorExecutionIds = computed<Set<NodeExecutionId>>(() => {
|
||||
const ids = new Set<NodeExecutionId>()
|
||||
const error = missingNodesError.value
|
||||
if (!error) return ids
|
||||
|
||||
for (const nodeType of error.nodeTypes) {
|
||||
if (typeof nodeType === 'string') continue
|
||||
if (nodeType.nodeId == null) continue
|
||||
for (const id of getAncestorExecutionIds(String(nodeType.nodeId))) {
|
||||
ids.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
return ids
|
||||
})
|
||||
|
||||
const activeMissingNodeGraphIds = computed<Set<string>>(() => {
|
||||
const ids = new Set<string>()
|
||||
if (!app.rootGraph) return ids
|
||||
|
||||
const activeGraph = canvasStore.currentGraph ?? app.rootGraph
|
||||
|
||||
for (const executionId of missingAncestorExecutionIds.value) {
|
||||
const graphNode = getNodeByExecutionId(app.rootGraph, executionId)
|
||||
if (graphNode?.graph === activeGraph) {
|
||||
ids.add(String(graphNode.id))
|
||||
}
|
||||
}
|
||||
|
||||
return ids
|
||||
})
|
||||
|
||||
/** Map of node errors indexed by locator ID. */
|
||||
const nodeErrorsByLocatorId = computed<Record<NodeLocatorId, NodeError>>(
|
||||
() => {
|
||||
@@ -196,15 +336,11 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
*/
|
||||
const errorAncestorExecutionIds = computed<Set<NodeExecutionId>>(() => {
|
||||
const ids = new Set<NodeExecutionId>()
|
||||
|
||||
for (const executionId of allErrorExecutionIds.value) {
|
||||
const parts = executionId.split(':')
|
||||
// Add every prefix including the full ID (error leaf node itself)
|
||||
for (let i = 1; i <= parts.length; i++) {
|
||||
ids.add(parts.slice(0, i).join(':'))
|
||||
for (const id of getAncestorExecutionIds(executionId)) {
|
||||
ids.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
return ids
|
||||
})
|
||||
|
||||
@@ -216,59 +352,26 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
return errorAncestorExecutionIds.value.has(execId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update node and slot error flags when validation errors change.
|
||||
* Propagates errors up subgraph chains.
|
||||
*/
|
||||
watch(lastNodeErrors, () => {
|
||||
if (!app.rootGraph) return
|
||||
/** True if the node has a missing node inside it at any nesting depth. */
|
||||
function isContainerWithMissingNode(node: LGraphNode): boolean {
|
||||
if (!app.rootGraph) return false
|
||||
const execId = getExecutionIdByNode(app.rootGraph, node)
|
||||
if (!execId) return false
|
||||
return missingAncestorExecutionIds.value.has(execId)
|
||||
}
|
||||
|
||||
// Clear all error flags
|
||||
forEachNode(app.rootGraph, (node) => {
|
||||
node.has_errors = false
|
||||
if (node.inputs) {
|
||||
for (const slot of node.inputs) {
|
||||
slot.hasErrors = false
|
||||
}
|
||||
}
|
||||
})
|
||||
watch(lastNodeErrors, () => {
|
||||
const rootGraph = app.rootGraph
|
||||
if (!rootGraph) return
|
||||
|
||||
clearAllNodeErrorFlags(rootGraph)
|
||||
|
||||
if (!lastNodeErrors.value) return
|
||||
|
||||
// Set error flags on nodes and slots
|
||||
for (const [executionId, nodeError] of Object.entries(
|
||||
lastNodeErrors.value
|
||||
)) {
|
||||
const node = getNodeByExecutionId(app.rootGraph, executionId)
|
||||
if (!node) continue
|
||||
|
||||
node.has_errors = true
|
||||
|
||||
// Mark input slots with errors
|
||||
if (node.inputs) {
|
||||
for (const error of nodeError.errors) {
|
||||
const slotName = error.extra_info?.input_name
|
||||
if (!slotName) continue
|
||||
|
||||
const slot = node.inputs.find((s) => s.name === slotName)
|
||||
if (slot) {
|
||||
slot.hasErrors = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Propagate errors to parent subgraph nodes
|
||||
const parts = executionId.split(':')
|
||||
for (let i = parts.length - 1; i > 0; i--) {
|
||||
const parentExecutionId = parts.slice(0, i).join(':')
|
||||
const parentNode = getNodeByExecutionId(
|
||||
app.rootGraph,
|
||||
parentExecutionId
|
||||
)
|
||||
if (parentNode) {
|
||||
parentNode.has_errors = true
|
||||
}
|
||||
}
|
||||
applyNodeError(rootGraph, executionId, nodeError)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -277,6 +380,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
lastNodeErrors,
|
||||
lastExecutionError,
|
||||
lastPromptError,
|
||||
missingNodesError,
|
||||
|
||||
// Clearing
|
||||
clearAllErrors,
|
||||
@@ -291,16 +395,22 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
hasExecutionError,
|
||||
hasPromptError,
|
||||
hasNodeError,
|
||||
hasMissingNodes,
|
||||
hasAnyError,
|
||||
allErrorExecutionIds,
|
||||
totalErrorCount,
|
||||
lastExecutionErrorNodeId,
|
||||
activeGraphErrorNodeIds,
|
||||
activeMissingNodeGraphIds,
|
||||
|
||||
// Missing node actions
|
||||
setMissingNodeTypes,
|
||||
surfaceMissingNodes,
|
||||
|
||||
// Lookup helpers
|
||||
getNodeErrors,
|
||||
slotHasError,
|
||||
errorAncestorExecutionIds,
|
||||
isContainerWithInternalError
|
||||
isContainerWithInternalError,
|
||||
isContainerWithMissingNode
|
||||
}
|
||||
})
|
||||
|
||||
@@ -345,3 +345,100 @@ describe('useExecutionErrorStore - Node Error Lookups', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useExecutionErrorStore - setMissingNodeTypes', () => {
|
||||
let store: ReturnType<typeof useExecutionErrorStore>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = useExecutionErrorStore()
|
||||
})
|
||||
|
||||
it('clears missingNodesError when called with an empty array', () => {
|
||||
store.setMissingNodeTypes([{ type: 'NodeA' }])
|
||||
store.setMissingNodeTypes([])
|
||||
expect(store.missingNodesError).toBeNull()
|
||||
})
|
||||
|
||||
it('hasMissingNodes is false when error is null', () => {
|
||||
store.setMissingNodeTypes([])
|
||||
expect(store.hasMissingNodes).toBe(false)
|
||||
})
|
||||
|
||||
it('hasMissingNodes is true after setting non-empty types', () => {
|
||||
store.setMissingNodeTypes([{ type: 'NodeA' }])
|
||||
expect(store.hasMissingNodes).toBe(true)
|
||||
})
|
||||
|
||||
it('deduplicates string entries by value', () => {
|
||||
store.setMissingNodeTypes(['GroupNode', 'GroupNode', 'OtherGroup'])
|
||||
expect(store.missingNodesError?.nodeTypes).toHaveLength(2)
|
||||
expect(store.missingNodesError?.nodeTypes).toEqual([
|
||||
'GroupNode',
|
||||
'OtherGroup'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a single string entry unchanged', () => {
|
||||
store.setMissingNodeTypes(['GroupNode'])
|
||||
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('deduplicates object entries with the same nodeId', () => {
|
||||
store.setMissingNodeTypes([
|
||||
{ type: 'NodeA', nodeId: 1 },
|
||||
{ type: 'NodeA', nodeId: 1 }
|
||||
])
|
||||
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps object entries with different nodeIds even if same type', () => {
|
||||
store.setMissingNodeTypes([
|
||||
{ type: 'NodeA', nodeId: 1 },
|
||||
{ type: 'NodeA', nodeId: 2 }
|
||||
])
|
||||
expect(store.missingNodesError?.nodeTypes).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('deduplicates object entries by type when nodeId is absent', () => {
|
||||
store.setMissingNodeTypes([{ type: 'NodeB' }, { type: 'NodeB' }])
|
||||
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps distinct types when nodeId is absent', () => {
|
||||
store.setMissingNodeTypes([{ type: 'NodeB' }, { type: 'NodeC' }])
|
||||
expect(store.missingNodesError?.nodeTypes).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('treats absent nodeId the same as type-only key (falls back to type)', () => {
|
||||
store.setMissingNodeTypes([{ type: 'NodeD' }, { type: 'NodeD' }])
|
||||
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('handles a mix of string and object entries correctly', () => {
|
||||
store.setMissingNodeTypes([
|
||||
'GroupNode',
|
||||
'GroupNode', // string dup
|
||||
{ type: 'NodeA', nodeId: 1 },
|
||||
{ type: 'NodeA', nodeId: 1 }, // object dup by nodeId
|
||||
{ type: 'NodeA', nodeId: 2 }, // same type, different nodeId → kept
|
||||
{ type: 'NodeB' },
|
||||
{ type: 'NodeB' } // object dup by type
|
||||
])
|
||||
// Unique: 'GroupNode', {NodeA,1}, {NodeA,2}, {NodeB} → 4
|
||||
expect(store.missingNodesError?.nodeTypes).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('stores a non-empty message string in missingNodesError', () => {
|
||||
store.setMissingNodeTypes([{ type: 'NodeA' }])
|
||||
expect(typeof store.missingNodesError?.message).toBe('string')
|
||||
expect(store.missingNodesError!.message.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('stores the deduplicated nodeTypes array in missingNodesError', () => {
|
||||
const input = [{ type: 'NodeA' }, { type: 'NodeB' }]
|
||||
store.setMissingNodeTypes(input)
|
||||
expect(store.missingNodesError?.nodeTypes).toEqual(input)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user